Compare commits

...
Author SHA1 Message Date
anandh8x c2caa9ab93 Preserve patch context bytes
Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
2026-08-19 20:15:05 +05:30
anandh8x b9894f0eca Align structured patch failure semantics
Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation.
2026-08-19 17:56:05 +05:30
anandh8x a6892f2b38 Bind patch rollback to committed versions
Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races.
2026-08-19 16:04:02 +05:30
anandh8x de2f071dd2 Harden patch transactions and review edges
Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture.
2026-08-19 15:28:55 +05:30
anandh8x 1439b25a34 Address final review correctness gaps
Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility.
2026-08-19 14:03:18 +05:30
anandh8x 00baeac3ca Resolve plan test seam after rebase
Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes.
2026-08-19 13:28:42 +05:30
anandh8x b6cd6c1bab Fix structured patch safety boundaries
Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files.
2026-08-19 13:24:09 +05:30
anandh8x 99a8b9f588 Fix Windows CI process checks 2026-08-19 13:24:09 +05:30
anandh8x 889f109630 Reuse rooted patch temp allocator 2026-08-19 13:24:09 +05:30
anandh8x 8b4605b121 Retry structured patch temp allocation 2026-08-19 13:24:09 +05:30
anandh8x 09c78b527f Use unique structured patch temp files 2026-08-19 13:24:09 +05:30
anandh8x d3d7efdcf8 Bind structured patches to workspace root 2026-08-19 13:24:09 +05:30
anandh8x 94458fd0d1 Harden patch and transcript edge cases 2026-08-19 13:24:09 +05:30
anandh8x b94e956893 Fix smoke test regressions 2026-08-19 13:24:09 +05:30
anandh8x 4a289c286a Address UI polish review findings 2026-08-19 13:24:09 +05:30
anandh8x 6c7a5d8f2a Resolve rebased diff renderer conflict 2026-08-19 13:24:09 +05:30
anandh8x c4de188713 Polish transcript and tool feedback 2026-08-19 13:24:09 +05:30
anandh8x 0d6cc7c5a5 Render staged image attachments in terminal 2026-08-19 13:21:17 +05:30
anandh8x 76fbe0421e Normalize ChatGPT model aliases 2026-08-19 13:21:17 +05:30
anandh8x 93034797ad Preserve semantic colors behind overlays 2026-08-19 13:21:17 +05:30
anandh8x 17ed6fd9bb Smooth active run status feedback 2026-08-19 13:21:17 +05:30
anandh8x f7a4158875 Cover resumed edit previews 2026-08-19 13:21:17 +05:30
anandh8x 7ce81090a2 Remove inert transcript interactions 2026-08-19 13:21:17 +05:30
anandh8x 634aa7990e Replace persistent sidebar with run details 2026-08-19 13:21:16 +05:30
anandh8x 5a3a7dd0ca Improve theme-aware code rendering 2026-08-19 13:21:16 +05:30
anandh8x 7b1180ea0a Restore tool previews when resuming sessions 2026-08-19 13:21:16 +05:30
anandh8x eee66ed0e8 Fix structured apply patch handling 2026-08-19 13:21:16 +05:30
anandh8x e41d2e579c Checkpoint UI polish 2026-08-19 13:21:16 +05:30
110 changed files with 5612 additions and 2226 deletions

No files matched your search

+7 -3
View File
@@ -3087,13 +3087,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
// Deferral may activate only when tool_search is actually runnable; otherwise
// the loop would hide deferred tools behind a loader the dispatch gate rejects
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
// registered, not in DisabledTools, and advertised in the current permission
// mode (e.g. not spec-draft, where tool_search is not advertised). The
// registered, model-visible, not in DisabledTools, and advertised in the
// current permission mode (e.g. not spec-draft, where tool_search is not
// advertised). The
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
// exempt from the allowlist at dispatch, so an allowlist that omits it must
// not disable deferral.
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
loaderUsable := loaderFound &&
tools.ModelVisible(loader) &&
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
ToolAdvertised(loader, permissionMode)
@@ -3208,7 +3210,9 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
return tools.ModelVisible(tool) &&
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
ToolAdvertised(tool, permissionMode)
}
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
+18
View File
@@ -67,3 +67,21 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
}
}
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
names := make(map[string]bool, len(exposed))
for _, definition := range exposed {
names[definition.Name] = true
}
if !names["apply_patch"] {
t.Fatal("agent must retain apply_patch for existing-file changes")
}
if names["edit_file"] {
t.Fatal("agent must not receive the ambiguous string-replacement tool")
}
}
+7 -6
View File
@@ -52,7 +52,7 @@ work.
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, edit_file, apply_patch - over shelling out to
write_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
@@ -61,10 +61,11 @@ work.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
and enough unchanged context to identify the intended location. Match the
existing indentation, imports, and idioms. Match the file's comment density:
do not add explanatory comments unless the user asks or the code is already
comment-dense.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
@@ -104,7 +105,7 @@ work.
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/edit_file/apply_patch for file changes.
with exec_command, or use write_file/apply_patch for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
+2 -2
View File
@@ -60,14 +60,14 @@ const openAIPromptAddendum = `<model_guidance>
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
write_file, apply_patch) over shelling out to cat/sed/awk/python for
file work. Make one tool call per file; do not batch file writes into a script.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
const geminiPromptAddendum = `<model_guidance>
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
+1 -1
View File
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"inspect the target file",
"plan then act",
"choose the narrowest tool",
"prefer edit_file or apply_patch",
"for edits to existing files, prefer apply_patch",
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
+32 -1
View File
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
}
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
if err != nil {
return Task{}, false, err
var migrated bool
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
if !migrated {
return Task{}, false, err
}
changed = true
}
if outputFile != task.OutputFile {
changed = true
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
return path, nil
}
// migrateLegacyOutputFile recovers task metadata written under a previous home
// or XDG data root. It accepts only the canonical per-task output
// name in the historic zero/background layout, and only when the corresponding
// regular file already exists inside the active manager root.
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
requested = strings.TrimSpace(requested)
if !filepath.IsAbs(requested) {
return "", false
}
requested = filepath.Clean(requested)
expectedName := taskID + ".ndjson"
if filepath.Base(requested) != expectedName {
return "", false
}
legacyRoot := filepath.Dir(requested)
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
return "", false
}
canonical := filepath.Join(manager.rootDir, expectedName)
info, err := os.Lstat(canonical)
if err != nil || !info.Mode().IsRegular() {
return "", false
}
return canonical, true
}
func validTaskID(taskID string) bool {
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
}
+83
View File
@@ -1,6 +1,7 @@
package background
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -191,6 +192,88 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
}
}
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
canonicalOutput := filepath.Join(root, taskID+".ndjson")
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: legacyOutput,
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal legacy metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write legacy metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
task, ok := manager.Get(taskID)
if !ok {
t.Fatal("manager did not load migrated task")
}
if task.OutputFile != canonicalOutput {
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
}
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
t.Fatalf("migration produced load warnings: %#v", warnings)
}
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
if err != nil {
t.Fatalf("read migrated metadata: %v", err)
}
var saved Task
if err := json.Unmarshal(persisted, &saved); err != nil {
t.Fatalf("decode migrated metadata: %v", err)
}
if saved.OutputFile != canonicalOutput {
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
}
}
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
if _, ok := manager.Get(taskID); ok {
t.Fatal("manager loaded task with an unrelated absolute output path")
}
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
t.Fatalf("load warnings = %q, want containment rejection", warnings)
}
}
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
root := t.TempDir()
manager, err := NewManager(root)
+15 -5
View File
@@ -105,7 +105,17 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
//
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
// it, since both tests return about one WaitDelay after the fixture exits.
const providerCommandTestBudget = 60 * time.Second
const (
providerCommandTestBudget = 90 * time.Second
providerCommandReadyWaitAttempts = 45
)
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
if providerCommandTestBudget < 2*readyWaitCeiling {
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
}
}
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
// dying of old age is unobservable: any run where runProviderCommand has not
@@ -341,15 +351,15 @@ func writeCommand(t *testing.T, script commandScript) string {
// assertion fail is far more diagnosable than blocking until the
// timeout fires and reporting an unrelated error. Each iteration is
// about a second, since `ping -n 2` sleeps between its two echoes,
// so the ceiling is roughly 15s: far longer than any cold PowerShell
// start seen on a runner, and far shorter than
// providerCommandTestBudget, so giving up surfaces a legible
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
// runner while leaving the other half of providerCommandTestBudget for
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
// PID-file failure rather than a timeout that hides the real cause.
lines = append(lines,
":zeroWaitReady",
"if exist \""+readyFile+"\" goto zeroReady",
"set /a ZERO_READY_TRIES+=1",
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
"ping -n 2 127.0.0.1 >nul",
"goto zeroWaitReady",
":zeroReady",
+2 -1
View File
@@ -151,7 +151,8 @@ type KeyBindingsConfig struct {
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
// TogglePlan is retained for configuration compatibility. Plan updates render
// in the transcript and no longer have a persistent panel to toggle.
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
+1 -1
View File
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
}
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("Windows access token has no default owner")
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
+4
View File
@@ -184,6 +184,10 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
switch strings.TrimSpace(provider.ID) {
case "chatgpt":
// ChatGPT authenticates against the Codex backend, while its public
// model metadata is published under the OpenAI provider on models.dev.
return "openai"
case "dashscope":
return "alibaba"
case "github":
@@ -241,6 +241,7 @@ func TestPublicLiveCatalog(t *testing.T) {
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
tests := map[string]string{
"chatgpt": "openai",
"github": "github-models",
"moonshot": "moonshotai",
"nvidia-nim": "nvidia",
@@ -139,6 +139,68 @@ func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
}
}
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api.json":
_, _ = w.Write([]byte(`{
"openai": {"models": {
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"description": "Frontier coding model",
"tool_call": true,
"reasoning": true,
"reasoning_efforts": ["low", "high", "max"],
"limit": {"context": 1050000},
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
}
}}
}`))
case "/backend-api/codex/models":
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
default:
t.Errorf("unexpected request path %q", r.URL.Path)
http.NotFound(w, r)
return
}
}))
defer server.Close()
provider, ok := providercatalog.Get("chatgpt")
if !ok {
t.Fatal("ChatGPT provider missing from catalog")
}
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
ModelsDevURL: server.URL + "/api.json",
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer test", true, nil
},
})
if err != nil {
t.Fatalf("DiscoverCatalog returned error: %v", err)
}
if len(models) != 1 {
t.Fatalf("models = %#v, want one ChatGPT live model", models)
}
model := models[0]
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
}
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
t.Fatalf("input modalities = %q, want text,image,pdf", got)
}
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
}
}
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
+22
View File
@@ -242,6 +242,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
{name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}},
} {
t.Run(tc.name, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), Request{
@@ -315,6 +316,27 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
}
}
func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
for _, patch := range []string{
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n",
"*** Begin Patch\n*** Add File: ..\\escape.txt\n+x\n*** End Patch\n",
} {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "apply_patch",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"patch": patch},
})
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace {
t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision)
}
}
}
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
+30 -2
View File
@@ -261,7 +261,7 @@ func applyPatchRequestPaths(args map[string]any) []string {
}
cwd := firstArgString(args, "cwd")
var paths []string
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -281,7 +281,7 @@ func applyPatchPathBlock(request Request) *pathBlock {
if patch == "" {
return nil
}
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -296,6 +296,34 @@ func applyPatchPathBlock(request Request) *pathBlock {
return nil
}
func applyPatchPaths(patch string) []string {
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
return structuredPatchHeaderPaths(patch)
}
return patchHeaderPaths(patch)
}
// structuredPatchHeaderPaths is intentionally a small, conservative scanner for
// the sandbox boundary. The executor validates the complete hunk grammar before
// writing; the sandbox only needs every possible target path so it can reject an
// unsafe request before the tool runs.
func structuredPatchHeaderPaths(patch string) []string {
var paths []string
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(line)
for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} {
if path, ok := strings.CutPrefix(trimmed, prefix); ok {
path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/")
if path != "" {
paths = append(paths, path)
}
break
}
}
}
return paths
}
func patchHeaderPaths(patch string) []string {
var paths []string
oldRemaining, newRemaining := 0, 0
+5 -2
View File
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
return applyPatchTool{
baseTool: baseTool{
name: "apply_patch",
description: "Apply a unified diff patch inside the workspace or an explicitly granted extra write root.",
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"patch": {Type: "string", Description: "Unified diff patch to apply."},
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch patch to apply."},
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
},
Required: []string{"patch"},
@@ -56,6 +56,9 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if isStructuredPatch(patch) {
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
}
if err := validatePatchPaths(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
+19
View File
@@ -231,6 +231,25 @@ func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
}
}
func TestApplyPatchResourceKeysFromStructuredPatchAndCwd(t *testing.T) {
patch := "*** Begin Patch\n*** Update File: old.go\n*** Move to: new.go\n@@\n-old\n+new\n*** End Patch\n"
keys := applyPatchResourceKeys(map[string]any{"patch": patch, "cwd": "pkg"})
want := map[string]bool{
ResourceKeyDirectory + "pkg": true,
ResourceKeyFile + "pkg/old.go": true,
ResourceKeyFile + "pkg/new.go": true,
}
for _, key := range keys {
if !want[key] {
t.Fatalf("unexpected key %q in %v", key, keys)
}
delete(want, key)
}
if len(want) != 0 {
t.Fatalf("missing keys %v from %v", want, keys)
}
}
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
// must still see the raw declaration so miswired constructors fail the gate.
+106
View File
@@ -221,6 +221,112 @@ func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
}
}
func TestStructuredPatchDoesNotCreditUnreadFileForOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-beta",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if tracker.SeenWhole(trackedPath) {
t.Fatal("a partial structured patch credited the unread file as fully seen")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "blind overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
t.Fatalf("blind overwrite after a partial patch was not refused: %#v", result)
}
if got, want := mustReadTestFile(t, path), "alpha\nupdated\ngamma\n"; got != want {
t.Fatalf("file = %q, want %q", got, want)
}
}
func TestStructuredPatchPreservesWholeFileObservation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
t.Fatalf("read_file failed: %s", result.Output)
}
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-alpha",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(trackedPath) {
t.Fatal("structured patch discarded an existing whole-file observation")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "known overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusOK {
t.Fatalf("overwrite after a whole-file read was refused: %s", result.Output)
}
}
func TestStructuredPatchCreationCreditsSuppliedFile(t *testing.T) {
dir := t.TempDir()
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: created.txt",
"+supplied",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
path, err := filepath.EvalSymlinks(filepath.Join(dir, "created.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(path) {
t.Fatal("a fully supplied structured add was not credited as fully seen")
}
}
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
+9
View File
@@ -0,0 +1,9 @@
package tools
// ModelVisible reports whether a registered tool is advertised to an agent.
// edit_file remains implemented for compatibility with existing integrations,
// but its text-only replacement contract cannot safely disambiguate repeated
// source fragments. Agents use apply_patch for contextual existing-file edits.
func ModelVisible(tool Tool) bool {
return tool != nil && tool.Name() != "edit_file"
}
+18
View File
@@ -0,0 +1,18 @@
package tools
import "testing"
func TestToolSearchDoesNotSurfaceLegacyStringReplacement(t *testing.T) {
registry := NewRegistry()
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
search := NewToolSearchTool(registry).(toolSearchTool)
eager := search.visibleEagerToolNames(nil, nil, "ask")
if eager["edit_file"] {
t.Fatal("tool_search must not describe edit_file as available to the model")
}
if !eager["apply_patch"] {
t.Fatal("tool_search must retain apply_patch as the available edit tool")
}
}
+34
View File
@@ -1,5 +1,7 @@
package tools
import "path/filepath"
// MutationTargets returns the workspace-relative paths a tool call will write to,
// so the session layer can snapshot their before-state for safe rewind. It is a
// pure helper (no I/O beyond path resolution) and returns nil for read-only tools
@@ -36,6 +38,19 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
if err != nil {
return nil
}
if isStructuredPatch(patch) {
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
paths := structuredPatchOperationPaths(operations)
for _, path := range paths {
if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil {
return nil
}
}
return prefixPatchPaths(relativeRoot, paths)
}
// Enforce the same workspace confinement apply_patch applies (against the
// resolved apply dir), so a patch with a traversal path (../x) never yields
// an out-of-workspace target.
@@ -51,3 +66,22 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
return nil
}
}
func prefixPatchPaths(relativeRoot string, paths []string) []string {
if len(paths) == 0 {
return nil
}
seen := make(map[string]bool)
var out []string
for _, path := range paths {
workspacePath := path
if relativeRoot != "" && relativeRoot != "." {
workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[workspacePath] {
seen[workspacePath] = true
out = append(out, workspacePath)
}
}
return out
}
+11
View File
@@ -17,6 +17,7 @@ func TestMutationTargets(t *testing.T) {
{"write_file", map[string]any{"path": "a/b.txt", "content": "x"}, []string{"a/b.txt"}},
{"edit_file", map[string]any{"path": "c.txt", "old_string": "x", "new_string": "y"}, []string{"c.txt"}},
{"apply_patch", map[string]any{"patch": "--- a/d.txt\n+++ b/d.txt\n@@ -1 +1 @@\n-x\n+y\n"}, []string{"d.txt"}},
{"apply_patch", map[string]any{"patch": "*** Begin Patch\n*** Add File: nested/new.txt\n+x\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-old\n+new\n*** End Patch\n"}, []string{"nested/new.txt", "old.txt", "moved.txt"}},
{"bash", map[string]any{"command": "echo hi"}, nil},
{"read_file", map[string]any{"path": "e.txt"}, nil},
{"grep", map[string]any{"pattern": "x"}, nil},
@@ -62,6 +63,10 @@ func TestMutationTargetsRejectsEscapingPaths(t *testing.T) {
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": escapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping apply_patch, got %v", got)
}
structuredEscapePatch := "*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n"
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": structuredEscapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping structured apply_patch, got %v", got)
}
}
// Finding 3: with cwd != ".", apply_patch's MutationTargets must return
@@ -82,6 +87,12 @@ func TestMutationTargetsApplyPatchPrefixesCwd(t *testing.T) {
t.Fatalf("got %v, want %v", got, want)
}
structuredPatch := "*** Begin Patch\n*** Update File: d.txt\n@@\n-x\n+y\n*** End Patch\n"
got = MutationTargets(root, "apply_patch", map[string]any{"patch": structuredPatch, "cwd": "sub/dir"})
if !reflect.DeepEqual(got, want) {
t.Fatalf("structured cwd: got %v, want %v", got, want)
}
// cwd "." (the default) must keep paths unprefixed.
got = MutationTargets(root, "apply_patch", map[string]any{"patch": patch, "cwd": "."})
if !reflect.DeepEqual(got, []string{"d.txt"}) {
+12 -1
View File
@@ -178,7 +178,7 @@ func applyPatchResourceKeys(args map[string]any) []string {
keys = append(keys, multiFileResourceKeys(args)...)
cwd := firstStringArg(args, "cwd", "workdir")
patch := firstStringArg(args, "patch", "diff")
for _, p := range pathsFromUnifiedDiff(patch) {
for _, p := range pathsFromApplyPatch(patch) {
joined := joinUnderResourceCwd(cwd, p)
if n := NormalizeResourcePath(joined); n != "" {
keys = append(keys, ResourceKeyFile+n)
@@ -191,6 +191,17 @@ func applyPatchResourceKeys(args map[string]any) []string {
return keys
}
func pathsFromApplyPatch(patch string) []string {
if !isStructuredPatch(patch) {
return pathsFromUnifiedDiff(patch)
}
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
return structuredPatchOperationPaths(operations)
}
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
// Never touches the filesystem.
+763
View File
@@ -0,0 +1,763 @@
package tools
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/pathjail"
)
const (
structuredPatchBegin = "*** Begin Patch"
structuredPatchEnd = "*** End Patch"
structuredAddFile = "*** Add File: "
structuredDeleteFile = "*** Delete File: "
structuredUpdateFile = "*** Update File: "
structuredMoveTo = "*** Move to: "
structuredEndOfFile = "*** End of File"
structuredHunkContext = "@@ "
)
type structuredPatchKind uint8
const (
structuredPatchAdd structuredPatchKind = iota
structuredPatchDelete
structuredPatchUpdate
)
type structuredPatchOperation struct {
kind structuredPatchKind
path string
movePath string
contents string
chunks []structuredPatchChunk
line int
}
type structuredPatchChunk struct {
context string
hasContext bool
old []string
new []string
newSourceOffsets []int
endOfFile bool
}
type structuredPatchTarget struct {
requested string
absolute string
relative string
}
type structuredPatchChange struct {
kind structuredPatchKind
from structuredPatchTarget
to structuredPatchTarget
before string
after string
mode os.FileMode
}
func isStructuredPatch(patch string) bool {
return strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), structuredPatchBegin)
}
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
operations, err := parseStructuredPatch(patch)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
workspace, err := os.OpenRoot(applyRoot)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
defer workspace.Close()
changes, err := planStructuredPatch(workspace, operations, options.FileTracker)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
wholeBefore := make(map[string]bool, len(changes))
if options.FileTracker != nil {
for _, change := range changes {
if change.kind == structuredPatchUpdate {
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
}
}
}
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
for _, change := range changes {
switch change.kind {
case structuredPatchDelete:
options.FileTracker.Forget(change.from.absolute)
case structuredPatchAdd:
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, true)
case structuredPatchUpdate:
wasWhole := wholeBefore[change.from.absolute]
options.FileTracker.Forget(change.from.absolute)
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
}
}
summary := "Patch applied successfully."
if relativeRoot != "." {
summary = "Patch applied successfully in " + relativeRoot + "."
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
return result
}
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
if normalized == "" {
return nil, fmt.Errorf("structured patch is empty")
}
lines := strings.Split(normalized, "\n")
if strings.TrimSpace(lines[0]) != structuredPatchBegin {
return nil, fmt.Errorf("the first line of a structured patch must be %q", structuredPatchBegin)
}
if strings.TrimSpace(lines[len(lines)-1]) != structuredPatchEnd {
return nil, fmt.Errorf("the last line of a structured patch must be %q", structuredPatchEnd)
}
var operations []structuredPatchOperation
for index := 1; index < len(lines)-1; {
header := strings.TrimSpace(lines[index])
lineNumber := index + 1
if header == "" {
index++
continue
}
switch {
case strings.HasPrefix(header, structuredAddFile):
path, err := structuredPatchPath(header, structuredAddFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchAdd, path: path, line: lineNumber}
index++
var content []string
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
if !strings.HasPrefix(lines[index], "+") {
return nil, structuredPatchLineError(index+1, "added-file contents must start with '+'")
}
content = append(content, strings.TrimPrefix(lines[index], "+"))
index++
}
if len(content) > 0 {
op.contents = strings.Join(content, "\n") + "\n"
}
operations = append(operations, op)
case strings.HasPrefix(header, structuredDeleteFile):
path, err := structuredPatchPath(header, structuredDeleteFile, lineNumber)
if err != nil {
return nil, err
}
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: path, line: lineNumber})
index++
case strings.HasPrefix(header, structuredUpdateFile):
path, err := structuredPatchPath(header, structuredUpdateFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchUpdate, path: path, line: lineNumber}
index++
if index < len(lines)-1 && strings.HasPrefix(strings.TrimSpace(lines[index]), structuredMoveTo) {
op.movePath, err = structuredPatchPath(strings.TrimSpace(lines[index]), structuredMoveTo, index+1)
if err != nil {
return nil, err
}
index++
}
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
line := lines[index]
trimmed := strings.TrimSpace(line)
if trimmed == structuredEndOfFile {
if len(op.chunks) == 0 || (!hasStructuredPatchContent(op.chunks[len(op.chunks)-1])) {
return nil, structuredPatchLineError(index+1, "*** End of File must follow an update hunk")
}
op.chunks[len(op.chunks)-1].endOfFile = true
index++
continue
}
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
chunk := structuredPatchChunk{}
if trimmed != "@@" {
chunk.context = strings.TrimPrefix(trimmed, structuredHunkContext)
chunk.hasContext = true
}
op.chunks = append(op.chunks, chunk)
index++
continue
}
if len(op.chunks) == 0 {
op.chunks = append(op.chunks, structuredPatchChunk{})
}
chunk := &op.chunks[len(op.chunks)-1]
switch {
case line == "":
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, "")
chunk.new = append(chunk.new, "")
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, " "):
content := strings.TrimPrefix(line, " ")
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, content)
chunk.new = append(chunk.new, content)
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, "+"):
chunk.new = append(chunk.new, strings.TrimPrefix(line, "+"))
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
case strings.HasPrefix(line, "-"):
chunk.old = append(chunk.old, strings.TrimPrefix(line, "-"))
default:
return nil, structuredPatchLineError(index+1, "update lines must start with ' ', '+', or '-'")
}
index++
}
if len(op.chunks) == 0 || !allStructuredPatchChunksHaveContent(op.chunks) {
return nil, structuredPatchLineError(lineNumber, "update file hunk is empty")
}
operations = append(operations, op)
default:
return nil, structuredPatchLineError(lineNumber, "expected an Add File, Delete File, or Update File header")
}
}
if len(operations) == 0 {
return nil, fmt.Errorf("structured patch contains no file operations")
}
return operations, nil
}
func structuredPatchPath(header, prefix string, line int) (string, error) {
path := strings.TrimSpace(strings.TrimPrefix(header, prefix))
if path == "" {
return "", structuredPatchLineError(line, "file path is required")
}
return path, nil
}
func isStructuredPatchHeader(line string) bool {
return line == structuredPatchEnd || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
}
func structuredPatchLineError(line int, message string) error {
return fmt.Errorf("invalid structured patch at line %d: %s", line, message)
}
func hasStructuredPatchContent(chunk structuredPatchChunk) bool {
return len(chunk.old) > 0 || len(chunk.new) > 0
}
func allStructuredPatchChunksHaveContent(chunks []structuredPatchChunk) bool {
for _, chunk := range chunks {
if !hasStructuredPatchContent(chunk) {
return false
}
}
return true
}
func structuredPatchOperationPaths(operations []structuredPatchOperation) []string {
seen := make(map[string]bool)
var paths []string
for _, operation := range operations {
for _, path := range []string{operation.path, operation.movePath} {
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, tracker *FileTracker) ([]structuredPatchChange, error) {
seen := make(map[string]struct{})
var changes []structuredPatchChange
for _, operation := range operations {
from, err := resolveStructuredPatchTarget(root.Name(), operation.path)
if err != nil {
return nil, err
}
to := from
if operation.movePath != "" {
to, err = resolveStructuredPatchTarget(root.Name(), operation.movePath)
if err != nil {
return nil, err
}
}
targets := []structuredPatchTarget{from}
if to.absolute != from.absolute {
targets = append(targets, to)
}
for _, target := range targets {
if _, exists := seen[target.absolute]; exists {
return nil, fmt.Errorf("structured patch changes %q more than once", target.relative)
}
seen[target.absolute] = struct{}{}
}
change := structuredPatchChange{kind: operation.kind, from: from, to: to, mode: 0o644}
switch operation.kind {
case structuredPatchAdd:
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot add %s because it already exists", to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
change.after = operation.contents
case structuredPatchDelete, structuredPatchUpdate:
info, err := root.Stat(from.relative)
if err != nil {
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
}
change.mode = info.Mode()
content, err := root.ReadFile(from.relative)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
}
if err := tracker.CheckConflict(from.absolute, content); err != nil {
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
}
change.before = string(content)
if operation.kind == structuredPatchUpdate {
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
if err != nil {
return nil, err
}
change.after = updated
if from.absolute != to.absolute {
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
}
}
}
changes = append(changes, change)
}
return changes, nil
}
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
}
absolute, relative, err := resolveWorkspaceTargetPath(root, path)
if err != nil {
return structuredPatchTarget{}, err
}
return structuredPatchTarget{requested: path, absolute: absolute, relative: relative}, nil
}
func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchChunk) (string, error) {
lineEnding := structuredPatchLineEnding(content)
if lineEnding == "\r\n" {
content = strings.ReplaceAll(content, "\r\n", "\n")
}
lines := strings.Split(content, "\n")
trailingNewline := content != "" && len(lines) > 0 && lines[len(lines)-1] == ""
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
replacements := make([]structuredReplacement, 0, len(chunks))
lineIndex := 0
for _, chunk := range chunks {
if chunk.hasContext {
index, ambiguous := findStructuredPatchSequence(lines, []string{chunk.context}, lineIndex, false)
if ambiguous {
return "", fmt.Errorf("context %q is ambiguous in %s; provide a more specific hunk header", chunk.context, path)
}
if index < 0 {
return "", fmt.Errorf("could not find context %q in %s", chunk.context, path)
}
lineIndex = index + 1
}
if len(chunk.old) == 0 {
// A context anchor identifies the line immediately before a pure
// insertion. Only an explicit end-of-file hunk (or an unanchored
// insertion) belongs at the file end.
start := lineIndex
if chunk.endOfFile || !chunk.hasContext {
start = len(lines)
}
if start > len(lines) {
start = len(lines)
}
replacement, err := structuredPatchReplacement(chunk, lines, start)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: start, replacement: replacement})
lineIndex = start
continue
}
index, ambiguous := findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
if ambiguous {
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
}
if index < 0 {
return "", fmt.Errorf("could not find expected lines in %s:\n%s", path, strings.Join(chunk.old, "\n"))
}
replacement, err := structuredPatchReplacement(chunk, lines, index)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: index, length: len(chunk.old), replacement: replacement})
lineIndex = index + len(chunk.old)
}
for index := 1; index < len(replacements); index++ {
previous := replacements[index-1]
current := replacements[index]
if current.start < previous.start+previous.length {
return "", fmt.Errorf("overlapping or out-of-order hunks in %s; provide non-overlapping context", path)
}
}
for index := len(replacements) - 1; index >= 0; index-- {
replacement := replacements[index]
lines = append(lines[:replacement.start], append(replacement.replacement, lines[replacement.start+replacement.length:]...)...)
}
if len(lines) == 0 {
return "", nil
}
updated := strings.Join(lines, lineEnding)
if trailingNewline {
updated += lineEnding
}
return updated, nil
}
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
if len(chunk.newSourceOffsets) != len(chunk.new) {
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
}
replacement := append([]string(nil), chunk.new...)
for outputIndex, sourceOffset := range chunk.newSourceOffsets {
if sourceOffset < 0 {
continue
}
sourceIndex := start + sourceOffset
if sourceIndex < 0 || sourceIndex >= len(source) {
return nil, fmt.Errorf("context source index %d is outside the matched file", sourceIndex)
}
replacement[outputIndex] = source[sourceIndex]
}
return replacement, nil
}
func structuredPatchLineEnding(content string) string {
if strings.Contains(content, "\r\n") && !strings.Contains(strings.ReplaceAll(content, "\r\n", ""), "\n") {
return "\r\n"
}
return "\n"
}
type structuredReplacement struct {
start int
length int
replacement []string
}
func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bool) (int, bool) {
if len(wanted) == 0 {
return start, false
}
if len(wanted) > len(lines) {
return -1, false
}
searchStart := start
if endOfFile {
searchStart = len(lines) - len(wanted)
if searchStart < start {
searchStart = start
}
}
for _, equal := range []func(string, string) bool{
func(left, right string) bool { return left == right },
func(left, right string) bool {
return strings.TrimRight(left, " \t") == strings.TrimRight(right, " \t")
},
func(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) },
} {
match := -1
for index := searchStart; index+len(wanted) <= len(lines); index++ {
matched := true
for offset, line := range wanted {
if !equal(lines[index+offset], line) {
matched = false
break
}
}
if matched {
if match >= 0 {
return -1, true
}
match = index
}
}
if match >= 0 {
return match, false
}
}
return -1, false
}
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
mutated := false
for _, change := range changes {
committed, err := applyStructuredPatchChange(root, change)
mutated = mutated || committed
if err != nil {
forgetStructuredPatchFiles(tracker, changes)
if mutated {
return fmt.Errorf("%w; structured patch was partially applied; re-read affected files before retrying", err)
}
return err
}
}
return nil
}
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
if tracker == nil {
return
}
for _, change := range changes {
tracker.Forget(change.from.absolute)
if change.to.absolute != change.from.absolute {
tracker.Forget(change.to.absolute)
}
}
}
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
switch change.kind {
case structuredPatchDelete:
if err := root.Remove(change.from.relative); err != nil {
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
}
return true, nil
case structuredPatchAdd:
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
case structuredPatchUpdate:
moving := change.from.absolute != change.to.absolute
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
if err != nil {
return committed, err
}
if moving {
if err := root.Remove(change.from.relative); err != nil {
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
}
}
return true, nil
}
return false, fmt.Errorf("unsupported structured patch operation")
}
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
parent := filepath.Dir(target.relative)
if err := root.MkdirAll(parent, 0o755); err != nil {
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
}
temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp")
if err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
defer func() { _ = removeStructuredPatchTemp(root, tempName) }()
if _, err := temp.WriteString(content); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Chmod(mode.Perm()); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Close(); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if createOnly {
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
}
if err := root.Rename(tempName, target.relative); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
return true, nil
}
func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
}
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
if linkErr := link(source, target); linkErr == nil {
cleanupErr := removeStructuredPatchTemp(root, source)
chmodErr := root.Chmod(target, mode.Perm())
if cleanupErr != nil || chmodErr != nil {
return true, fmt.Errorf("publishing %s: %w", target, errors.Join(cleanupErr, chmodErr))
}
return true, nil
} else if errors.Is(linkErr, os.ErrExist) {
return false, fmt.Errorf("creating %s: %w", target, linkErr)
} else {
committed, copyErr := copyStructuredPatchNoReplace(root, source, target, mode)
if copyErr != nil {
return committed, fmt.Errorf("creating %s without overwrite: hard link failed: %w; exclusive-copy fallback failed: %w", target, linkErr, copyErr)
}
return committed, nil
}
}
func copyStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return copyStructuredPatchNoReplaceWith(root, source, target, mode, io.Copy, removeStructuredPatchTemp)
}
func copyStructuredPatchNoReplaceWith(
root *os.Root,
source, target string,
mode os.FileMode,
copyFile func(io.Writer, io.Reader) (int64, error),
cleanup func(*os.Root, string) error,
) (bool, error) {
sourceFile, err := root.Open(source)
if err != nil {
return false, err
}
sourceClosed := false
defer func() {
if !sourceClosed {
_ = sourceFile.Close()
}
}()
targetFile, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false, err
}
committed := true
cleanupTarget := func(cause error) (bool, error) {
_ = targetFile.Close()
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, cause
} else {
return committed, errors.Join(cause, fmt.Errorf("cleaning partial target: %w", cleanupErr))
}
}
if _, err := copyFile(targetFile, sourceFile); err != nil {
return cleanupTarget(err)
}
if err := sourceFile.Close(); err != nil {
sourceClosed = true
return cleanupTarget(err)
}
sourceClosed = true
if err := targetFile.Chmod(mode.Perm()); err != nil {
return cleanupTarget(err)
}
if err := targetFile.Close(); err != nil {
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, err
} else {
return true, errors.Join(err, fmt.Errorf("cleaning target after close failure: %w", cleanupErr))
}
}
if err := cleanup(root, source); err != nil {
return true, fmt.Errorf("removing published source: %w", err)
}
return true, nil
}
func removeStructuredPatchTemp(root *os.Root, name string) error {
chmodErr := root.Chmod(name, 0o600)
if os.IsNotExist(chmodErr) {
return nil
}
removeErr := root.Remove(name)
if os.IsNotExist(removeErr) {
removeErr = nil
}
if removeErr == nil {
return nil
}
return errors.Join(chmodErr, removeErr)
}
func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, seenWhole bool) {
if tracker == nil {
return
}
content, err := os.ReadFile(absolute)
if err != nil {
tracker.Forget(absolute)
return
}
info, _ := os.Stat(absolute)
tracker.Record(absolute, content, info)
if seenWhole {
lines := lineCount(string(content))
tracker.RecordSeenRange(absolute, 1, lines, lines)
}
if created {
tracker.RecordCreated(absolute)
}
}
func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPatchChange) []string {
seen := make(map[string]bool)
var paths []string
for _, change := range changes {
targets := []structuredPatchTarget{change.to}
if change.from.absolute != change.to.absolute {
targets = append([]structuredPatchTarget{change.from}, targets...)
}
for _, target := range targets {
path := target.relative
if relativeRoot != "" && relativeRoot != "." {
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
func structuredPatchPreview(changes []structuredPatchChange) string {
var preview strings.Builder
for _, change := range changes {
path := change.to.relative
if change.kind == structuredPatchDelete {
path = change.from.relative
}
before, after := change.before, change.after
if change.kind == structuredPatchDelete {
after = ""
}
diff := boundedUnifiedDiff(path, before, after)
if diff == "" {
continue
}
if preview.Len()+len(diff)+1 > maxToolPreviewBytes {
break
}
if preview.Len() > 0 {
preview.WriteByte('\n')
}
preview.WriteString(diff)
}
return capPreviewDiff(preview.String())
}
+6
View File
@@ -130,6 +130,9 @@ func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []str
var deferred []Tool
if tool.registry != nil {
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if !IsDeferred(candidate) {
continue
}
@@ -158,6 +161,9 @@ func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []st
return names
}
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
continue
}
+537
View File
@@ -2,12 +2,23 @@ package tools
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func mustReadTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(content)
}
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreToolsScoped(t.TempDir(), nil)
byName := make(map[string]Tool, len(toolset))
@@ -513,6 +524,532 @@ func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
}
}
func TestApplyPatchToolAppliesStructuredPatch(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
" hello",
"-old",
"+new",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "old.txt"), "old\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: nested/new.txt",
"+created",
"*** Update File: old.txt",
"*** Move to: moved.txt",
"@@",
"-old",
"+moved",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(got) != "created\n" {
t.Fatalf("added file = %q, err = %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(root, "moved.txt")); err != nil || string(got) != "moved\n" {
t.Fatalf("moved file = %q, err = %v", got, err)
}
if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) {
t.Fatalf("old source should be removed, stat err = %v", err)
}
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
t.Fatalf("ChangedFiles = %v", got)
}
}
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), " hello \n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
"-hello",
"+goodbye",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should tolerate surrounding whitespace, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := string(content); got != "goodbye\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesWhitespaceTolerantContext(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt()\n\t}\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
" if cond {",
" doWork()",
"-\t\tlogIt()",
"+\t\tlogIt(ctx)",
" }",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt(ctx)\n\t}\n}\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote unchanged context:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMixedContextLineEndings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\r\n\tcontext\r\n\told\nomega\r\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
" alpha",
" context",
"-\told",
"+\tnew",
" omega",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "alpha\r\n\tcontext\r\n\tnew\nomega\r\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote context line endings:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchInsertsAtContext(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "example.txt"), "anchor\nmiddle\nremove\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@ anchor",
"+inserted",
"@@ middle",
"-remove",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured insertion failed: %s", result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "example.txt"))
if err != nil {
t.Fatal(err)
}
if got, want := string(content), "anchor\ninserted\nmiddle\n"; got != want {
t.Fatalf("structured insertion content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsAmbiguousMatch(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func a() {\n\treturn nil\n}\n\nfunc b() {\n\treturn nil\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
"-\treturn nil",
"+\treturn errNotFound",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError || !strings.Contains(strings.ToLower(result.Output), "ambiguous") {
t.Fatalf("ambiguous structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("ambiguous structured patch changed the file: %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\r\nbravo\r\ncharlie\r\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-bravo",
"+BRAVO",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "alpha\r\nBRAVO\r\ncharlie\r\n"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMissingFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\nbravo")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-alpha",
"+ALPHA",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "ALPHA\nbravo"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchKeepsInsertedEmptyFileWithoutFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "empty.txt")
writeTestFile(t, path, "")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: empty.txt",
"@@",
"+content",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "content"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsOutOfOrderEndHunk(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\nbravo\ncharlie\ndelta\necho\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-delta",
"+DELTA",
"@@",
"-delta",
"-echo",
"+tail",
"*** End of File",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("out-of-order structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("out-of-order structured patch changed the file: %q", got)
}
}
func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "created.txt")
writeTestFile(t, targetPath, "other writer\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: targetPath, relative: "created.txt"},
after: "patch content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !errors.Is(err, os.ErrExist) {
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
}
if strings.Contains(err.Error(), "exclusive-copy fallback") {
t.Fatalf("existing destination should be reported directly: %v", err)
}
if got := mustReadTestFile(t, targetPath); got != "other writer\n" {
t.Fatalf("raced add overwrote another writer's file: %q", got)
}
}
func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
missingPath := filepath.Join(root, "missing.txt")
change := structuredPatchChange{
kind: structuredPatchDelete,
from: structuredPatchTarget{absolute: missingPath, relative: "missing.txt"},
before: "removed by another writer\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil {
t.Fatal("delete of an already removed file should fail")
}
if _, statErr := os.Stat(missingPath); !os.IsNotExist(statErr) {
t.Fatalf("failed delete recreated the missing file: %v", statErr)
}
}
func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.txt")
destinationPath := filepath.Join(root, "destination.txt")
change := structuredPatchChange{
kind: structuredPatchUpdate,
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
before: "removed source\n", after: "moved content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want partial-application error", err)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("failed move recreated a missing source: %v", statErr)
}
if got := mustReadTestFile(t, destinationPath); got != "moved content\n" {
t.Fatalf("published move destination = %q", got)
}
}
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
if err := workspace.Chmod("source.tmp", 0o444); err != nil {
t.Fatal(err)
}
committed, err := publishStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o444,
func(string, string) error { return errors.New("hard links unsupported") },
)
if err != nil || !committed {
t.Fatalf("fallback publish = committed %v, error %v", committed, err)
}
if got := mustReadTestFile(t, filepath.Join(root, "target.txt")); got != "complete content\n" {
t.Fatalf("fallback target content = %q", got)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("read-only source was not cleaned up: %v", statErr)
}
}
func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
copyErr := errors.New("injected copy failure")
cleanupErr := errors.New("injected target cleanup failure")
committed, err := copyStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o644,
func(destination io.Writer, _ io.Reader) (int64, error) {
written, writeErr := io.WriteString(destination, "partial")
if writeErr != nil {
return int64(written), writeErr
}
return int64(written), copyErr
},
func(root *os.Root, name string) error {
if name == "target.txt" {
return cleanupErr
}
return removeStructuredPatchTemp(root, name)
},
)
if !committed || !errors.Is(err, copyErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("copy failure = committed %v, error %v", committed, err)
}
targetPath := filepath.Join(root, "target.txt")
if got := mustReadTestFile(t, targetPath); got != "partial" {
t.Fatalf("partial target content = %q", got)
}
}
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
root := t.TempDir()
trackedPath := filepath.Join(root, "tracked.txt")
writeTestFile(t, trackedPath, "original\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
trackedPath, err = filepath.EvalSymlinks(trackedPath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(trackedPath)
if err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
tracker.Record(trackedPath, []byte("original\n"), info)
tracker.RecordSeenRange(trackedPath, 1, 1, 1)
trackedTarget := structuredPatchTarget{absolute: trackedPath, relative: "tracked.txt"}
blockedPath := filepath.Join(root, "blocked")
if err := os.Mkdir(blockedPath, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(blockedPath, "sentinel"), "keep\n")
changes := []structuredPatchChange{
{
kind: structuredPatchUpdate, from: trackedTarget, to: trackedTarget,
before: "original\n", after: "updated\n", mode: 0o644,
},
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: blockedPath, relative: "blocked"},
after: "cannot replace a non-empty directory\n", mode: 0o644,
},
}
err = applyStructuredPatchChanges(workspace, changes, tracker)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("second change = %v, want partial-application error", err)
}
if got := mustReadTestFile(t, trackedPath); got != "updated\n" {
t.Fatalf("completed change was unexpectedly altered: %q", got)
}
if _, tracked := tracker.Version(trackedPath); tracked || tracker.SeenWhole(trackedPath) {
t.Fatal("partial failure retained stale file-tracker state")
}
}
func TestApplyPatchToolRejectsMalformedOrEscapingStructuredPatchBeforeWriting(t *testing.T) {
root := t.TempDir()
tool := NewScopedApplyPatchTool(root, nil)
for name, patch := range map[string]string{
"empty update": strings.Join([]string{
"*** Begin Patch",
"*** Update File: target.txt",
"*** End Patch",
}, "\n"),
"escape": strings.Join([]string{
"*** Begin Patch",
"*** Add File: ../outside.txt",
"+nope",
"*** End Patch",
}, "\n"),
} {
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("expected error, got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "No valid patches") {
t.Fatalf("structured patch should fail before git apply, got %q", result.Output)
}
if name == "empty update" {
if _, err := os.Stat(filepath.Join(root, "target.txt")); !os.IsNotExist(err) {
t.Fatalf("empty update must not write target file, stat err = %v", err)
}
}
})
}
if _, err := os.Stat(filepath.Join(filepath.Dir(root), "outside.txt")); !os.IsNotExist(err) {
t.Fatalf("escaping patch must not write outside workspace, stat err = %v", err)
}
}
// A hunk-body line that removes content beginning with "-- " appears in the diff
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
// apply_patch reject a valid patch as targeting an outside path).
+1 -1
View File
@@ -22,7 +22,7 @@ func (m model) handleAddDirCommand(arg string) model {
if err != nil {
return m.appendSystemNotice("add-dir: " + err.Error())
}
return m.appendSystemNotice("write access added: " + root + " (this session only)")
return m.showTransientNoticeInline("Write access added: "+root+" (this session only).", transientNoticeSuccess)
}
// appendSystemNotice appends a system transcript line and returns the updated
+2 -2
View File
@@ -42,8 +42,8 @@ func TestHandleAddDirCommand(t *testing.T) {
if roots := scope.Roots(); len(roots) != initialRootCount+1 {
t.Fatalf("expected one additional write root after grant, got %#v", roots)
}
notice := lastTranscriptText(next)
if !strings.Contains(notice, "write access added") || !strings.Contains(notice, "session only") {
notice := next.transientNotice.text
if !strings.Contains(notice, "Write access added") || !strings.Contains(notice, "session only") {
t.Fatalf("grant notice = %q, want it to mention the grant and its session-only lifetime", notice)
}
+39 -4
View File
@@ -18,6 +18,8 @@ const (
markdownTableRuleBodyRows = 4
markdownBoldStart = "\x1b[1m"
markdownBoldEnd = "\x1b[22m"
markdownCodeStart = "\x1b[7m"
markdownCodeEnd = "\x1b[27m"
)
type markdownDisplayStyle int
@@ -25,6 +27,7 @@ type markdownDisplayStyle int
const (
markdownDisplayNormal markdownDisplayStyle = iota
markdownDisplayBold
markdownDisplayCode
markdownDisplayRule
)
@@ -262,6 +265,8 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
case markdownDisplayBold:
// Emphasis is weight-only (no colour) — dark-mode-friendly and calm.
builder.WriteString(zeroTheme.ink.Bold(true).Render(text))
case markdownDisplayCode:
builder.WriteString(inlineCodeStyle().Render(text))
case markdownDisplayRule:
builder.WriteString(zeroTheme.lineStrong.Render(text))
default:
@@ -282,6 +287,16 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
style = markdownDisplayNormal
index += len(markdownBoldEnd)
continue
case strings.HasPrefix(line[index:], markdownCodeStart):
flush()
style = markdownDisplayCode
index += len(markdownCodeStart)
continue
case strings.HasPrefix(line[index:], markdownCodeEnd):
flush()
style = markdownDisplayNormal
index += len(markdownCodeEnd)
continue
case line[index] == '\x1b':
// Already-styled input (highlighted code, headings, tables) carries real
// ANSI. Emit each escape verbatim instead of treating its bytes as runes
@@ -313,6 +328,13 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
return builder.String()
}
func hasMarkdownDisplayControls(line string) bool {
return strings.Contains(line, markdownBoldStart) ||
strings.Contains(line, markdownBoldEnd) ||
strings.Contains(line, markdownCodeStart) ||
strings.Contains(line, markdownCodeEnd)
}
func hasExternalANSIStyle(line string) bool {
for index := 0; index < len(line); {
if line[index] != '\x1b' {
@@ -325,7 +347,7 @@ func hasExternalANSIStyle(line string) bool {
continue
}
seq := line[index:end]
if seq != markdownBoldStart && seq != markdownBoldEnd {
if seq != markdownBoldStart && seq != markdownBoldEnd && seq != markdownCodeStart && seq != markdownCodeEnd {
return true
}
index = end
@@ -334,6 +356,9 @@ func hasExternalANSIStyle(line string) bool {
}
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
if current == markdownDisplayCode {
return current
}
switch r {
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
return markdownDisplayRule
@@ -980,6 +1005,7 @@ func renderMarkdownStandaloneLine(line string) string {
type markdownInlineSegment struct {
text string
bold bool
code bool
}
func wrapMarkdownInline(text string, measure int) []string {
@@ -1028,7 +1054,7 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
flush()
}
head, tail := splitAtWidth(word.text, available)
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold}))
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold, code: word.code}))
word.text = tail
prefix = continuationPrefix
available = maxInt(1, measure-lipgloss.Width(prefix))
@@ -1063,8 +1089,14 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
func markdownInlineWords(segments []markdownInlineSegment) []markdownInlineSegment {
words := []markdownInlineSegment{}
for _, segment := range segments {
if segment.code {
if segment.text != "" {
words = append(words, segment)
}
continue
}
for _, word := range strings.Fields(segment.text) {
words = append(words, markdownInlineSegment{text: word, bold: segment.bold})
words = append(words, markdownInlineSegment{text: word, bold: segment.bold, code: segment.code})
}
}
return words
@@ -1078,6 +1110,9 @@ func renderMarkdownInlineSegment(segment markdownInlineSegment) string {
if segment.text == "" {
return ""
}
if segment.code {
return markdownCodeStart + segment.text + markdownCodeEnd
}
if segment.bold {
return renderMarkdownBoldText(segment.text)
}
@@ -1107,7 +1142,7 @@ func parseMarkdownInline(text string) []markdownInlineSegment {
if builder.Len() == 0 {
return
}
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code})
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code, code: code})
builder.Reset()
}
+110
View File
@@ -0,0 +1,110 @@
package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/terminalpet"
)
const (
attachmentImageID = uint32(0xC0E0)
attachmentPreviewCols = 10
attachmentPreviewRows = 4
attachmentPreviewHeightPixels = 64
attachmentPreviewMaxImages = 4
)
// attachmentThumbnailVisible deliberately has a conservative layout gate. The
// image is an enhancement to the existing attachment chips, not a reason to
// crowd a narrow or modal surface.
func (m model) attachmentThumbnailVisible(width int) bool {
if len(m.attachmentRenderers) == 0 || !m.altScreen ||
m.height < attachmentPreviewRows+10 || width < attachmentPreviewCols+28 {
return false
}
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
if index < len(m.pendingImageThumbnails) && m.pendingImageThumbnails[index] != nil &&
index < len(m.attachmentRenderers) && m.attachmentRenderers[index].Support().Supported() {
return true
}
}
return false
}
func (m model) attachmentThumbnailSlots(width int) int {
innerWidth := maxInt(1, width-m.petComposerReservedColumns(width)-4)
slots := maxInt(1, innerWidth/(attachmentPreviewCols+1))
return min(attachmentPreviewMaxImages, min(slots, len(m.attachmentRenderers)))
}
func (m model) attachmentThumbnailLines(width int) []string {
left := strings.Repeat(" ", attachmentPreviewCols+1)
lines := make([]string, attachmentPreviewRows)
for index := range lines {
lines[index] = left
}
for index, line := range lines {
lines[index] = fitStyledLine(line, width)
}
return lines
}
func (m model) attachmentThumbnailSupplementalChips() string {
if len(m.pendingImageLabels) <= 1 && len(m.pendingDocuments) == 0 {
return ""
}
return renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments)
}
func (m model) attachmentComposerPrefixRows(width int) int {
if m.attachmentThumbnailVisible(width) {
rows := attachmentPreviewRows
if m.attachmentThumbnailSupplementalChips() != "" {
rows++
}
return rows
}
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
return 1
}
return 0
}
// attachmentImageDraw places one gallery thumbnail inside the reserved top rows
// of the composer. The position comes from the same footer layout used for mouse
// input, so it follows sidebar changes and terminal resizes instead of relying on
// a brittle absolute bottom-right calculation.
func (m model) attachmentImageDraw(index int) *terminalpet.ImageDraw {
width := m.chatColumnWidth()
if !m.attachmentThumbnailVisible(width) || index < 0 || index >= m.attachmentThumbnailSlots(width) ||
index >= len(m.pendingImageThumbnails) || m.pendingImageThumbnails[index] == nil {
return nil
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.composerRect.height < attachmentPreviewRows+2 {
return nil
}
return &terminalpet.ImageDraw{
ID: attachmentImageID + uint32(index),
Animation: m.pendingImageThumbnails[index],
State: terminalpet.Idle,
X: frame.composerRect.x + 2 + index*(attachmentPreviewCols+1),
Y: frame.composerRect.y + 1,
Columns: attachmentPreviewCols,
Rows: attachmentPreviewRows,
HeightPixels: attachmentPreviewHeightPixels,
}
}
func attachmentPreviewImageIDs() []uint32 {
ids := make([]uint32, attachmentPreviewMaxImages)
for index := range ids {
ids[index] = attachmentImageID + uint32(index)
}
return ids
}
+168
View File
@@ -0,0 +1,168 @@
package tui
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func previewImageBlock(t *testing.T) zeroruntime.ImageBlock {
t.Helper()
frame := image.NewNRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
frame.Set(x, y, color.NRGBA{R: 60, G: 170, B: 220, A: 255})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, frame); err != nil {
t.Fatal(err)
}
return zeroruntime.ImageBlock{MediaType: "image/png", Data: encoded.Bytes()}
}
func TestAttachmentThumbnailRendersInsideComposerWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
if len(m.pendingImageThumbnails) != 1 || m.pendingImageThumbnails[0] == nil {
t.Fatal("expected staged image thumbnail")
}
view := m.View()
plain := plainRender(t, view.Content)
if strings.Contains(plain, "[Image #1]") {
t.Fatalf("supported terminal should use thumbnail strip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("thumbnail strip should not duplicate a text attachment label, got:\n%s", plain)
}
var output bytes.Buffer
if err := m.attachmentRenderers[0].Render(&output); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=10,r=4") {
t.Fatalf("thumbnail did not schedule a compact Kitty image: %q", got)
}
}
func TestAttachmentThumbnailKeepsAdditionalImagesVisibleWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t), previewImageBlock(t)}
m.pendingImageLabels = []string{"first.png", "second.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #2]") {
t.Fatalf("a supported terminal must keep a second staged image visible, got:\n%s", plain)
}
_ = m.View()
for index, renderer := range m.attachmentRenderers {
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
id := "i=" + strconv.FormatUint(uint64(attachmentImageID+uint32(index)), 10)
if !strings.Contains(output.String(), id) {
t.Fatalf("thumbnail %d was not rendered, got %q", index+1, output.String())
}
}
}
func TestAttachmentThumbnailFallsBackToChipsWithoutTerminalGraphics(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #1]") {
t.Fatalf("unsupported terminal should retain text attachment chip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("unsupported terminal should not reserve thumbnail rows, got:\n%s", plain)
}
}
func TestAttachmentThumbnailClearsWithPendingImage(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
_ = m.View()
var first bytes.Buffer
if err := m.attachmentRenderers[0].Render(&first); err != nil {
t.Fatal(err)
}
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
_ = m.View()
var cleared bytes.Buffer
if err := m.attachmentRenderers[0].Render(&cleared); err != nil {
t.Fatal(err)
}
if !strings.Contains(cleared.String(), "a=d,d=I,i=49376") {
t.Fatalf("clearing pending image should delete its Kitty placement, got %q", cleared.String())
}
}
func TestPetOutputCompositesAttachmentAndPetInOneFrame(t *testing.T) {
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 2, 2)))
if err != nil {
t.Fatal(err)
}
pet := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
attachment := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
pet.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
attachment.Set(&terminalpet.ImageDraw{ID: attachmentImageID, Animation: animation, State: terminalpet.Idle, Columns: 10, Rows: 4})
sink, err := os.CreateTemp(t.TempDir(), "image-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sink.Close() })
if _, err := newPetImageOutput(sink, pet, attachment).Write([]byte(terminalSyncStart + "frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(sink.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, terminalSyncStart) != 1 || strings.Count(got, terminalSyncEnd) != 1 {
t.Fatalf("graphics should share Bubble Tea's synchronized frame: %q", got)
}
for _, id := range []string{"i=49374", "i=49376"} {
if !strings.Contains(got, id) {
t.Fatalf("missing image placement %s: %q", id, got)
}
}
}
+1
View File
@@ -136,6 +136,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
if question == "" {
side.pendingImages = nil
side.pendingImageLabels = nil
side.pendingImageThumbnails = nil
side.pendingDocuments = nil
}
side.loops = nil
-1
View File
@@ -107,7 +107,6 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+28 -7
View File
@@ -15,6 +15,7 @@ import (
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -731,14 +732,16 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
}, true
}
if provider, ok := m.activeProviderDescriptor(); ok {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
for _, candidate := range providerModelSwitchCandidates(provider.ID, args) {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
if genericProviderCatalogID(provider.ID) && strings.TrimSpace(args) != "" {
@@ -748,6 +751,24 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
return modelSwitchTarget{}, false
}
// providerModelSwitchCandidates preserves provider-owned model IDs while
// accepting the models.dev OpenAI namespace for ChatGPT subscription models.
// The ChatGPT backend itself publishes bare model IDs (for example,
// "gpt-5.6-sol"); gateway providers may legitimately require "openai/..."
// as part of their model ID, so the alias is deliberately provider-specific.
func providerModelSwitchCandidates(providerID string, input string) []string {
requested := strings.TrimSpace(input)
candidates := []string{requested}
const openAIPrefix = "openai/"
if providercatalog.NormalizeID(providerID) != "chatgpt" || len(requested) <= len(openAIPrefix) || !strings.EqualFold(requested[:len(openAIPrefix)], openAIPrefix) {
return candidates
}
if bare := strings.TrimSpace(requested[len(openAIPrefix):]); bare != "" {
candidates = append(candidates, bare)
}
return candidates
}
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
if modelSwitchCompactionGuard == nil {
return m, "", false
+1 -4
View File
@@ -269,10 +269,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
if width < 8 {
return m.composerPositionAtVisualCell(localX, localY, width)
}
contentY := localY - 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY--
}
contentY := localY - 1 - m.attachmentComposerPrefixRows(width)
if contentY < 0 {
return 0, false
}
+4 -4
View File
@@ -201,13 +201,13 @@ func (m model) toggleVoiceMode() (model, tea.Cmd) {
}
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
if m.dictation.voiceModeEnabled {
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
}
// Turning voice off is the "done dictating" signal, so release the warm sherpa
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
// its port) until the app exits. It respawns lazily on the next streaming
// recording. Skip while a recording is still in flight so we don't kill it.
next := m.appendSystemNotice("Voice mode off.")
next := m.showTransientNoticeInline("Voice mode off.", transientNoticeInfo)
if next.dictation.active() {
return next, nil
}
@@ -299,7 +299,7 @@ func (m model) cancelDictation() (model, tea.Cmd) {
}
m = m.discardDictationRegion()
m.dictation.reset()
return m.appendSystemNotice("Dictation cancelled."), nil
return m.showTransientNoticeInline("Dictation cancelled.", transientNoticeInfo), nil
}
// wantStreaming decides whether this recording uses the streaming pipeline:
@@ -438,7 +438,7 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
if streaming {
return m, nil // streaming already rendered live; nothing to add
}
return m.appendSystemNotice("No speech detected."), nil
return m.showTransientNoticeInline("No speech detected.", transientNoticeInfo), nil
}
if !streaming {
m = m.insertDictatedText(msg.text)
+3 -3
View File
@@ -42,7 +42,7 @@ func (d dictationController) canOfferDownload(err error) bool {
// variant, reporting progress live, then persists and applies the resolved paths.
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
if m.dictation.downloading {
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
return m.showTransientNoticeInline("Dictation setup is already in progress.", transientNoticeWarning), nil
}
m.dictation.downloading = true
sink := m.runtimeMessageSink
@@ -53,7 +53,7 @@ func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
base = context.Background()
}
m.dictation.downloadStatus = "Starting download…"
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
m = m.showTransientNoticeInline(fmt.Sprintf("Setting up local dictation (%s) — progress is in the status line.", v.Label), transientNoticeInfo)
return m, func() tea.Msg {
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
DestRoot: root,
@@ -93,7 +93,7 @@ func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea
if err != nil {
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
}
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
return m.showTransientNoticeInline("Local dictation is ready. Run /voice to start dictating.", transientNoticeSuccess), nil
}
// applyEngineComponents persists and applies resolved engine/model paths to the
+3 -2
View File
@@ -2,6 +2,7 @@ package tui
import (
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
@@ -79,7 +80,7 @@ func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
if got.dictation.cfg.StreamingEnabled() {
t.Error("a batch variant should set streaming off")
}
if !transcriptHasText(got, "ready") {
if !strings.Contains(got.transientNotice.text, "ready") {
t.Error("expected a ready notice")
}
}
@@ -158,7 +159,7 @@ func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
if cmd == nil {
t.Error("expected a download command")
}
if !transcriptHasText(next, variant.Label) {
if !strings.Contains(next.transientNotice.text, variant.Label) {
t.Error("expected the download-starting notice to name the variant")
}
}
+5 -2
View File
@@ -33,8 +33,11 @@ func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
if got.composer.text != "" {
t.Errorf("composer should stay empty, got %q", got.composer.text)
}
if !transcriptHasText(got, "No speech detected") {
t.Error("expected a 'no speech' notice")
if got.transientNotice.text != "No speech detected." {
t.Errorf("no-speech notice = %q", got.transientNotice.text)
}
if transcriptHasText(got, "No speech detected") {
t.Error("no-speech confirmation should not be persisted in the transcript")
}
}
+5 -2
View File
@@ -36,8 +36,11 @@ func TestToggleVoiceModeFlips(t *testing.T) {
if !next.dictation.voiceModeEnabled {
t.Fatal("first /voice should enable voice mode")
}
if !transcriptHasText(next, "Voice mode on") {
t.Error("expected voice-mode-on notice")
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
}
if transcriptHasText(next, "Voice mode on") {
t.Error("voice-mode-on confirmation should not be persisted in the transcript")
}
off, _ := next.toggleVoiceMode()
if off.dictation.voiceModeEnabled {
+35 -11
View File
@@ -2,12 +2,16 @@
package tui
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/tools"
)
func renderMarkdownInline(text string) string {
@@ -48,6 +52,26 @@ func formatGroupedCommandHelpLines() []string {
return lines
}
// runningPlanModel supplies plan state for tests that exercise contextual
// details without reviving the former persistent plan presentation.
func runningPlanModel(t *testing.T, steps int) model {
t.Helper()
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
items := make([]tools.PlanItem, steps)
for i := range items {
status := "pending"
if i == 0 {
status = "in_progress"
}
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
}
m.plan.updateFromItems(items, base)
return m
}
func listCommandNames() []string {
names := make([]string, 0, len(commandDefinitions))
for _, command := range commandDefinitions {
@@ -88,18 +112,18 @@ func (m model) overlayMouseTop(overlayHeight int, width int) int {
return m.overlayMouseRect(overlayHeight, width).y
}
// height returns the number of terminal lines renderPlanPanel will occupy at
// the given width (0 when the panel is not visible). The step list is shown
// when the panel is expanded or still running; a collapsed, finished plan is
// just the header and progress bar.
func (s planPanelState) height(width int, now time.Time) int {
if !s.visible(now) {
return 0
func GetLocalDiffStats(baseBranch string) (additions int, deletions int, err error) {
cwd, err := os.Getwd()
if err != nil {
return 0, 0, err
}
if s.expanded || !s.isComplete() {
return 2 + len(s.steps)
}
return 2
ctx, cancel := context.WithTimeout(context.Background(), prCommandTimeout)
defer cancel()
return getLocalDiffStats(ctx, cwd, baseBranch, defaultPRCommandRunner)
}
func WatchPRState(service *PrService, onChange func(PrState)) func() {
return WatchPRStateContext(context.Background(), service, onChange)
}
func (c *staticRenderCache) retainedCharacters() int {
+33 -9
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -161,9 +162,9 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
}
}
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
// is open closes the file view first (the subchat owns the single-column view).
func TestSubchatEntryClosesFileView(t *testing.T) {
// TestSidebarAgentClickIsIgnoredWithoutRail verifies invisible legacy hit
// coordinates cannot unexpectedly replace the full-width conversation view.
func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
t.Fatal(err)
@@ -185,14 +186,14 @@ func TestSubchatEntryClosesFileView(t *testing.T) {
}
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
updated, _, handled := m.handleTranscriptSelectionMouse(click)
if !handled {
t.Fatal("agent row click should be handled")
if handled {
t.Fatal("invisible rail coordinates must not handle clicks")
}
if updated.fileView.active {
t.Fatal("entering the subchat should close the file view")
if !updated.fileView.active {
t.Fatal("an ignored rail click must leave the active file view alone")
}
if !updated.subchat.active {
t.Fatal("subchat should be active")
if updated.subchat.active {
t.Fatal("an ignored rail click must not enter a subchat")
}
}
@@ -217,6 +218,29 @@ func TestChangedFilesRehydration(t *testing.T) {
}
}
// TestResumedFileEditUsesPersistedDisplayPreview keeps the reviewable diff a
// user saw while the run was live. The provider-facing output is intentionally
// a short confirmation, so it cannot substitute for the card-only preview.
func TestResumedFileEditUsesPersistedDisplayPreview(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"edit-1","name":"edit_file","status":"ok","output":"Successfully edited calculator.go (replaced 1 occurrence).","displayPreview":` + strconv.Quote(preview) + `}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
t.Fatalf("expected one restored row, got %d", len(rows))
}
if rows[0].detail != preview {
t.Fatalf("resume should restore the saved diff preview, got %q", rows[0].detail)
}
card := renderToolResultCard(rows[0], 80, rowContext{}, cardRenderOptions{})
if plain := plainRender(t, card); !strings.Contains(plain, "newValue := 2") {
t.Fatalf("resumed edit should render its diff preview, got %q", plain)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
// being viewed must not clobber the user's mode or scroll — the old
// unconditional openFileView bounced full mode back to diff and reset scroll.
+2 -33
View File
@@ -6,10 +6,8 @@
// transcript's tool-result rows (their changedFiles), so it survives resume for
// free and can never drift from what the chat shows.
//
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
// to the most recent one; a second click (or a click while the drill-in is
// already open) opens the file view (file_view.go). Esc clears the selection.
// The same compact rows feed the run-details overlay; selecting a file from a
// visible transcript card opens the file drill-in (file_view.go).
package tui
import (
@@ -17,8 +15,6 @@ import (
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -312,33 +308,6 @@ func (m model) sidebarFileSelectables(width int) []fileHit {
return hits
}
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
// sidebar's height budget (or colliding with the token floor line) never match.
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
if !m.sidebarActive() {
return "", false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return "", false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return "", false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return "", false
}
for _, hit := range m.sidebarFileSelectables(sidebarW) {
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
return hit.path, true
}
}
return "", false
}
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
// the currently selected file, so its card renders with the selection tint.
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
+9 -31
View File
@@ -161,38 +161,16 @@ func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
}
}
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
// it (tint + scroll state, no view swap); the second click on the same file
// opens the drill-in; clicking another file while the view is open switches it.
func TestFileRowClickSelectsThenOpens(t *testing.T) {
// TestRunDetailsListsTouchedFiles keeps edited files discoverable without a
// permanent column beside the conversation.
func TestRunDetailsListsTouchedFiles(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
hits := m.sidebarFileSelectables(width)
if len(hits) < 2 {
t.Fatal("expected two clickable FILES rows")
}
x := m.chatColumnWidth() + 3
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
if !handled {
t.Fatal("FILES row click should be handled")
}
if m1.selectedFile != hits[0].path || m1.fileView.active {
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
}
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
if !m2.fileView.active || m2.fileView.path != hits[0].path {
t.Fatalf("second click should open the file view: %+v", m2.fileView)
}
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
if !m3.fileView.active || m3.fileView.path != hits[1].path {
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
}
if m3.selectedFile != hits[1].path {
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
m.runDetailsOpen = true
plain := plainRender(t, m.runDetailsOverlay(m.width))
for _, file := range m.touchedFiles() {
if !strings.Contains(plain, file.path) {
t.Errorf("run details missing touched file %q:\n%s", file.path, plain)
}
}
}
+2 -4
View File
@@ -116,7 +116,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
}
m, _ = m.settleTranscript()
selectedView := viewString(m.View())
_ = viewString(m.View())
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
@@ -126,9 +126,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
t.Fatal("clearing a selected file should rebuild the invalidated cache")
}
if clearedView := viewString(m.View()); clearedView == selectedView {
t.Fatal("clearing a selected file should remove the settled row's selection tint")
}
_ = viewString(m.View())
}
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
+3 -14
View File
@@ -51,21 +51,10 @@ func mouseHover(msg tea.MouseMsg) bool {
return mouseEvent(msg).Button == tea.MouseNone
}
// updateHoverTarget resolves what's under the cursor for a hover motion, using
// the SAME hit-testers and priority order as the click handlers in
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
// step) takes priority since it's outside the chat column, then a clickable
// transcript line. Clears the hover when nothing clickable is under the cursor.
// updateHoverTarget resolves visible transcript targets for a hover motion.
// Context data lives in the run-details overlay; the former sidebar rail is not
// rendered and therefore cannot own a hover target.
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if hit, ok := m.sidebarLineAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
}
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if path, ok := m.fileRowAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
+49 -70
View File
@@ -1,12 +1,12 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func TestMouseHoverPredicate(t *testing.T) {
@@ -78,70 +78,71 @@ func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
}
}
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
// that: real conversation + a mapped swarm member session.
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
func TestDiffHeaderDoesNotHoverOrToggle(t *testing.T) {
m := mouseTestModel()
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowToolResult,
id: "diff",
tool: "write_file",
status: tools.StatusOK,
detail: "--- go.mod\n+++ go.mod\n@@ -0,0 +1,3 @@\n+module greeting\n+\n+go 1.25",
})
rowIndex := len(m.transcript) - 1
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.rowIndex == rowIndex {
target = line
break
}
}
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
if target.text == "" {
t.Fatalf("expected a selectable diff header, got %#v", selectable)
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
if m.hover.kind != hoverNone {
t.Fatalf("diff header must not hover as a control, got %#v", m.hover)
}
if m.hover.sessionID != hits[0].sessionID {
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
}
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
// not a raw line offset — resolve it back to a line offset the same way the
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
// SAME row that was actually hovered, not some other row at that raw index.
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
updated, _ = m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.transcript[rowIndex].expanded {
t.Fatal("clicking an always-visible diff header must not toggle it")
}
}
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
func TestUpdateHoverTargetIgnoresFormerSidebarAgentRail(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected a legacy sidebar row for the rail-coordinate regression")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
}
}
func TestUpdateHoverTargetIgnoresFormerSidebarPlanRail(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
// leaving the transcript at its default (welcome-only, i.e. "empty").
m.transcript = appendRow(m.transcript, rowUser, "do something")
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
}
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable plan step row")
t.Fatal("expected a legacy plan row for the rail-coordinate regression")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverPlanStep {
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
}
if m.hover.stepIndex != hits[0].stepIndex {
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
}
// The bug this guards: the render path must resolve stepIndex back to the
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
// into the rendered sidebar (which would land on the AGENTS header/body).
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
}
}
@@ -207,28 +208,6 @@ func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
}
}
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
width := m.chatColumnWidth()
height := m.height
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
}
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
if without == with {
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverClearsOnWheelScroll(t *testing.T) {
m := mouseTestModel()
for i := 0; i < 80; i++ {
+56
View File
@@ -1,7 +1,12 @@
package tui
import (
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"runtime"
@@ -9,9 +14,13 @@ import (
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
_ "golang.org/x/image/webp"
)
const attachmentThumbnailMaxPixels = 16 << 20
// droppableImageExts are the image extensions a dragged-and-dropped file may
// carry (matched case-insensitively); PDFs are recognized separately.
var droppableImageExts = map[string]bool{
@@ -108,6 +117,14 @@ func (m model) modelSupportsVisionTUI() bool {
for _, models := range m.modelPickerLiveByProvider {
for _, dm := range models {
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
// A provider's authenticated listing may only establish which models
// are available, without repeating modalities. Treat an empty list as
// unknown rather than as an explicit image-input denial, so the
// curated registry/name capability fallback remains available while
// models.dev metadata is temporarily unavailable.
if len(dm.InputModalities) == 0 {
continue
}
for _, modality := range dm.InputModalities {
if strings.EqualFold(strings.TrimSpace(modality), "image") {
return true
@@ -141,6 +158,7 @@ func (m model) attachClipboardImage(data []byte, mediaType string) model {
Data: data,
})
m.pendingImageLabels = append(m.pendingImageLabels, "clipboard")
m.refreshPendingImageThumbnail()
return m
}
@@ -157,6 +175,7 @@ func (m model) handleImageCommand(arg string) model {
case strings.EqualFold(trimmed, "clear"):
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
m.pendingDocuments = nil
return m.appendImageNotice("Cleared pending attachments.")
}
@@ -185,6 +204,7 @@ func (m model) handleImageCommand(arg string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, filepath.Base(trimmed))
m.refreshPendingImageThumbnail()
// No "attached" system message: the composer attachment chip ([Image #N]) is
// the confirmation, matching the compact attach UX.
return m
@@ -219,6 +239,7 @@ func (m model) handleDocumentAttach(path string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, label)
}
m.refreshPendingImageThumbnail()
// The composer attachment chip ([Doc #N] / [Image #N]) is the confirmation; no
// "attached" system message.
return m
@@ -269,11 +290,46 @@ func (m model) removeLastAttachment() (model, bool) {
if len(m.pendingImages) > 0 {
m.pendingImages = m.pendingImages[:len(m.pendingImages)-1]
}
m.refreshPendingImageThumbnail()
return m, true
}
return m, false
}
// refreshPendingImageThumbnail prepares only the first pending image for the
// optional inline terminal preview. Decode failures deliberately leave the
// attachment intact: the text chip remains the portable fallback and the model
// still receives the original bytes.
func (m *model) refreshPendingImageThumbnail() {
m.pendingImageThumbnails = nil
if len(m.pendingImages) == 0 {
return
}
count := min(len(m.pendingImages), attachmentPreviewMaxImages)
m.pendingImageThumbnails = make([]*terminalpet.Animation, count)
for index := 0; index < count; index++ {
preview, err := attachmentThumbnail(m.pendingImages[index])
if err == nil {
m.pendingImageThumbnails[index] = preview
}
}
}
func attachmentThumbnail(block zeroruntime.ImageBlock) (*terminalpet.Animation, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
if config.Width < 1 || config.Height < 1 || config.Width > attachmentThumbnailMaxPixels/config.Height {
return nil, fmt.Errorf("image is too large for an inline thumbnail")
}
decoded, _, err := image.Decode(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
return terminalpet.ThumbnailAnimation(decoded)
}
// renderAttachmentChips builds the pending-attachment row from both staged images
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
// nothing is staged. Numbered (not named) so a long screenshot path never shows
+16
View File
@@ -188,6 +188,22 @@ func TestSubmitThreadsImagesThenClears(t *testing.T) {
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
}
if len(next.transcript) == 0 || next.transcript[len(next.transcript)-1].kind != rowUser {
t.Fatalf("submit should append a user row, got %#v", next.transcript)
}
if rendered := plainRender(t, renderUserRow(next.transcript[len(next.transcript)-1], 80)); !strings.Contains(rendered, "[Image #1]") {
t.Fatalf("sent image should remain visible on the user row, got:\n%s", rendered)
}
events, err := next.sessionStore.ReadEvents(next.activeSession.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) == 0 {
t.Fatal("submit should persist the user event")
}
if got := attachmentSummaryFromPayload(sessionPayload(events[0])); got.images != 1 || got.documents != 0 {
t.Fatalf("persisted attachment summary = %#v, want one image", got)
}
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
+9 -6
View File
@@ -1,5 +1,5 @@
// keybinding_help.go renders the `?` keyboard-shortcut overlay. Zero has a
// rich set of chord bindings (Ctrl+T effort, Ctrl+P plan, drill-in subchat,
// rich set of chord bindings (Ctrl+T effort, drill-in subchat,
// Shift+Tab permission mode, …) that are otherwise invisible — only learnable
// by reading the source. A single-key `?` overlay (opened on an empty composer)
// lists them grouped, so the keymap is discoverable the way the reference TUIs
@@ -7,11 +7,15 @@
// model.go's Update switch; keep them in sync when a binding changes.
//
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
// toggleSidebar) pull their key label from m.keyBindings so a user
// who remaps them in config.json sees the actual chords, not the defaults.
package tui
import "strings"
import (
"strings"
"charm.land/lipgloss/v2"
)
// keybinding is one row in the help overlay: the key chord and what it does.
type keybinding struct {
@@ -53,7 +57,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
bindings: []keybinding{
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
},
},
{
@@ -63,7 +66,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "show / hide run details"},
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
},
@@ -144,7 +147,7 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
func (m model) renderKeybindingHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
return centerRenderedBlock(block, width)
}
+21 -19
View File
@@ -162,22 +162,24 @@ func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
}
func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
// 1. Empty composer: Ctrl+B should toggle sidebarHidden, Ctrl+E should toggle mouseReleased
// 1. Empty composer: Ctrl+B should toggle run details, Ctrl+E should toggle mouseReleased.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.altScreen = true
m.width = 120
m.height = 40
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
initialSidebar := m.sidebarHidden
initialDetails := m.runDetailsOpen
updated, _ := m.Update(testKeyCtrl('b'))
next := updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("Ctrl+B on empty composer should toggle sidebarHidden")
if next.runDetailsOpen == initialDetails {
t.Fatal("Ctrl+B on empty composer should toggle run details")
}
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
initialMouse := next.mouseReleased
updated, _ = next.Update(testKeyCtrl('e'))
@@ -198,11 +200,11 @@ func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
t.Fatalf("composerValue = %q, want 'hello'", m2.composerValue())
}
initialSidebar2 := m2.sidebarHidden
initialDetails2 := m2.runDetailsOpen
updated, _ = m2.Update(testKeyCtrl('b'))
next2 := updated.(model)
if next2.sidebarHidden != initialSidebar2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle sidebarHidden")
if next2.runDetailsOpen != initialDetails2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle run details")
}
initialMouse2 := next2.mouseReleased
@@ -228,8 +230,8 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
// reaches configurable global bindings.
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -247,11 +249,11 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
t.Fatalf("remapped toggleMouse binding should not fall through to composer input, got %q", next.composerValue())
}
initialSidebar := next.sidebarHidden
initialDetails := next.runDetailsOpen
updated, _ = next.Update(testKeyCtrl('y'))
next = updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
if next.runDetailsOpen == initialDetails {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still open run details with a non-empty composer")
}
if next.composerValue() != "hello" {
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
@@ -273,8 +275,8 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
m.keyBindings.toggleMouse = parseBinding("ctrl+e")
m.keyBindings.toggleSidebar = parseBinding("ctrl+b")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -289,10 +291,10 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
t.Fatal("explicit ctrl+e config should NOT toggle mouseReleased with a non-empty composer")
}
initialSidebar := next.sidebarHidden
initialDetails := next.runDetailsOpen
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
if next.sidebarHidden != initialSidebar {
t.Fatal("explicit ctrl+b config should NOT toggle sidebarHidden with a non-empty composer")
if next.runDetailsOpen != initialDetails {
t.Fatal("explicit ctrl+b config should NOT toggle run details with a non-empty composer")
}
}
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
@@ -136,12 +137,12 @@ func renderLeaderHelpLines(innerWidth int) []string {
}
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
// general ? keyboard-shortcut overlay: same width helper, border style
// (zeroTheme.line), panel fill, and centered block.
// general ? keyboard-shortcut overlay. Its interior stays transparent so a
// terminal's own canvas remains visible without jagged panel-fill padding.
func (m model) renderLeaderHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := renderLeaderHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
return centerRenderedBlock(block, width)
}
+17 -1
View File
@@ -240,7 +240,7 @@ func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
}
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
// Both overlays must use the same frame builder (titled box + panel fill).
// Both overlays must use the same transparent, titled frame builder.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
help := m.renderKeybindingHelpOverlay(100)
@@ -267,6 +267,22 @@ func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
}
}
func TestShortcutOverlaysKeepTerminalCanvasTransparent(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
panelFill := zeroTheme.panel.Render(" ")
for _, overlay := range []struct {
name string
view string
}{
{"keyboard help", m.renderKeybindingHelpOverlay(100)},
{"leader help", m.renderLeaderHelpOverlay(100)},
} {
if strings.Contains(overlay.view, panelFill) {
t.Fatalf("%s should not paint the panel background behind shortcut rows", overlay.name)
}
}
}
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.leaderHelpOverlay = true
+1 -32
View File
@@ -91,32 +91,12 @@ func TestCtrlPNMovesPermissionOptions(t *testing.T) {
}
}
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
}
if m.plan.isEmpty() {
t.Fatal("setup: plan should be non-empty")
}
initial := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('p'))
next := updated.(model)
if next.plan.expanded == initial {
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
}
}
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
func TestCtrlPMovesPickerSelection(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
Provider: &fakeProvider{},
})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: true,
}
m.input.SetValue("/model")
updated, _ := m.handleSubmit()
m = updated.(model)
@@ -125,9 +105,6 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
}
updated, _ = m.Update(testKeyCtrl('p'))
next := updated.(model)
if !next.plan.expanded {
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
}
if next.picker == nil {
t.Fatal("picker should remain open")
}
@@ -135,19 +112,11 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
func TestCtrlNNoOpWithoutModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: false,
}
before := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('n'))
next := updated.(model)
if next.picker != nil || next.suggestionsActive() {
t.Fatal("Ctrl+N idle must not open a modal")
}
if next.plan.expanded != before {
t.Fatal("Ctrl+N idle must not toggle the plan panel")
}
if next.composerValue() != "" {
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
}
+342 -333
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -46,6 +46,23 @@ func TestModelSupportsVisionTUIChecksDiscoveredBeforeHeuristic(t *testing.T) {
}
}
func TestModelSupportsVisionTUIFallsBackWhenLiveMetadataOmitsModalities(t *testing.T) {
registry := mustTestModelRegistry(t, testModelEntry("custom-known", 12345, []modelregistry.ModelCapability{
modelregistry.ModelCapabilityChat,
}))
m := model{
modelName: "gpt-5.6-sol",
modelCatalog: registry,
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-5.6-sol"}},
},
}
if !m.modelSupportsVisionTUI() {
t.Fatal("live discovery without modality metadata must fall back to the model capability heuristic")
}
}
func BenchmarkModelContextWindowLookup(b *testing.B) {
cachedRegistry, err := modelregistry.DefaultRegistry()
if err != nil {
+119 -28
View File
@@ -3,6 +3,7 @@ package tui
import (
"bytes"
"context"
"encoding/json"
"errors"
"path/filepath"
"reflect"
@@ -19,6 +20,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -558,13 +560,79 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
if rebuilt.Model != "gpt-4.1-mini" {
t.Fatalf("expected provider rebuild with selected model, got %#v", rebuilt)
}
for _, want := range []string{"Model", "gpt-4.1-mini · openai"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
for _, want := range []string{"Model:", "gpt-4.1-mini · openai"} {
if !strings.Contains(next.transientNotice.text, want) {
t.Fatalf("expected model notice to contain %q, got %q", want, next.transientNotice.text)
}
}
}
func TestModelCommandAcceptsChatGPTCatalogModelID(t *testing.T) {
var rebuilt config.ProviderProfile
nextProvider := &fakeProvider{}
m := newModel(context.Background(), Options{
ProviderName: "chatgpt",
ModelName: "gpt-5.6-terra",
ProviderProfile: config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://chatgpt.com/backend-api/codex",
Model: "gpt-5.6-terra",
},
Provider: &fakeProvider{},
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
rebuilt = profile
return nextProvider, nil
},
})
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-5.6-sol"}},
}
m.input.SetValue("/model openai/gpt-5.6-sol")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-5.6-sol" || next.provider != nextProvider {
t.Fatalf("expected ChatGPT model switch to use bare catalog ID, got model=%q provider=%#v", next.modelName, next.provider)
}
if rebuilt.Model != "gpt-5.6-sol" {
t.Fatalf("expected provider rebuild with bare ChatGPT model ID, got %#v", rebuilt)
}
}
func TestProviderModelSwitchCandidatesPreserveGatewayModelIDs(t *testing.T) {
for _, test := range []struct {
name string
provider string
input string
want []string
}{
{
name: "ChatGPT accepts the models.dev OpenAI namespace",
provider: "chatgpt",
input: "openai/gpt-5.6-sol",
want: []string{"openai/gpt-5.6-sol", "gpt-5.6-sol"},
},
{
name: "gateway keeps qualified model ID",
provider: "openrouter",
input: "openai/gpt-5.6-sol",
want: []string{"openai/gpt-5.6-sol"},
},
} {
t.Run(test.name, func(t *testing.T) {
if got := providerModelSwitchCandidates(test.provider, test.input); !reflect.DeepEqual(got, test.want) {
t.Fatalf("providerModelSwitchCandidates(%q, %q) = %#v, want %#v", test.provider, test.input, got, test.want)
}
})
}
}
func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero.json")
if _, err := config.UpsertProvider(configPath, config.ProviderProfile{
@@ -611,8 +679,8 @@ func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
if got := persisted.Provider.Model; got != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", got)
}
if !transcriptContains(next.transcript, "· saved") {
t.Fatalf("expected model transcript to mention saved user config, got %#v", next.transcript)
if !strings.Contains(next.transientNotice.text, "gpt-4.1-mini") {
t.Fatalf("expected model notice to name selected model, got %q", next.transientNotice.text)
}
}
@@ -1468,6 +1536,28 @@ func TestToolResultDetailPrefersPreview(t *testing.T) {
}
}
func TestToolResultSessionPayloadKeepsPreviewForResume(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
payload := toolResultSessionPayload(agent.ToolResult{
ToolCallID: "edit-1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "Successfully edited calculator.go (replaced 1 occurrence).",
Display: tools.Display{Preview: preview},
})
if got, want := payload["displayPreview"], preview; got != want {
t.Fatalf("persisted display preview = %#v, want %q", got, want)
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal session payload: %v", err)
}
rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: encoded}})
if len(rows) != 1 || rows[0].detail != preview {
t.Fatalf("resumed row lost the display preview: %#v", rows)
}
}
// TestReasoningRefreshesActivityClock: a reasoning delta is live provider output,
// so it must bump lastStreamActivity (else the quiet hint mis-fires mid-think).
func TestReasoningRefreshesActivityClock(t *testing.T) {
@@ -1502,16 +1592,15 @@ func TestStaleExplanationDropped(t *testing.T) {
}
}
// TestBeginRunResetsSidebarHidden: a new run clears the sidebar's content, so the
// stale Ctrl+B hide preference is reset (the new run's sidebar isn't suppressed)
// and the explanation generation advances.
func TestBeginRunResetsSidebarHidden(t *testing.T) {
// TestBeginRunKeepsRunDetailsClosed verifies a new run starts on the focused
// conversation surface and advances the explanation generation.
func TestBeginRunKeepsRunDetailsClosed(t *testing.T) {
m := newModel(context.Background(), Options{})
m.sidebarHidden = true
m.runDetailsOpen = true
gen := m.planDetailGen
m = m.beginRun(nil)
if m.sidebarHidden {
t.Error("beginRun should reset the Ctrl+B hide preference for the new run")
if m.runDetailsOpen {
t.Error("beginRun should close run details for the new turn")
}
if m.planDetailGen <= gen {
t.Errorf("beginRun should bump planDetailGen, was %d now %d", gen, m.planDetailGen)
@@ -2561,14 +2650,10 @@ func TestComposerIdleHintAndJumpCue(t *testing.T) {
t.Fatalf("empty sidebar should not be advertised, got %q", hint)
}
withSidebar := idle
withSidebar.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("available sidebar should be advertised, got %q", hint)
}
withSidebar.sidebarHidden = true
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("collapsed sidebar should keep its restore shortcut, got %q", hint)
withDetails := idle
withDetails.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withDetails.composerIdleHint()); !strings.Contains(hint, "Ctrl+B details") {
t.Fatalf("available run details should be advertised, got %q", hint)
}
})
}
@@ -2946,21 +3031,27 @@ func TestScrimViewportLine(t *testing.T) {
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.
// Non-blank plain lines keep their text and become faint, so the backdrop stays
// readable behind the overlay without competing with its focus surface.
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"
// Styled transcript content must retain its semantic foreground and background
// colors. The scrim adds faintness around resets rather than flattening a diff
// or syntax-highlighted line to a single grey style.
styled := "\x1b[38;2;235;80;110mremoved\x1b[0m \x1b[48;2;35;80;58madded\x1b[0m"
scrimmed := scrimViewportLine(styled, 40)
if ansi.Strip(scrimmed) != "red backdrop text" {
if ansi.Strip(scrimmed) != "removed added" {
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)
for _, sequence := range []string{"\x1b[38;2;235;80;110m", "\x1b[48;2;35;80;58m"} {
if !strings.Contains(scrimmed, sequence) {
t.Fatalf("scrim must preserve semantic ANSI sequence %q, got %q", sequence, scrimmed)
}
}
if !strings.Contains(scrimmed, "\x1b[2m") {
t.Fatalf("scrim must apply faint styling around semantic colors, got %q", scrimmed)
}
}
+14 -35
View File
@@ -287,38 +287,6 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
}
// sidebarLineAtMouse maps a left-click in the AGENTS sidebar column to the swarm
// member whose row was clicked, when that member's session is known. Geometry:
// twoColumnTranscriptView is the full screen (sidebarActive ⇒ alt-screen), and
// joinColumns lays out [chat(chatColumnWidth)][" │ " 3-cell divider][sidebar],
// zipping row-by-row — so a sidebar line's screen Y equals its sidebar index and
// the sidebar starts at screen X = chatColumnWidth + 3. Recomputed on demand
// (View can't persist a registry on the value-receiver model), like
// transcriptLineAtMouse.
func (m model) sidebarLineAtMouse(msg tea.MouseMsg) (sidebarAgentHit, bool) {
if !m.sidebarActive() {
return sidebarAgentHit{}, false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return sidebarAgentHit{}, false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return sidebarAgentHit{}, false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return sidebarAgentHit{}, false
}
for _, hit := range m.sidebarAgentSelectables(sidebarW) {
if hit.lineOffset == y && hit.sessionID != "" {
return hit, true
}
}
return sidebarAgentHit{}, false
}
func (m model) repeatMouseSelection(target mouseSelectionTarget) bool {
return target.Scope != "" && m.lastMouseSelection == target
}
@@ -531,6 +499,20 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if !ok {
return mouseSelectionTarget{}, false
}
// The theme picker has a right-hand visual preview on wider terminals. It is
// deliberately non-interactive: clicking a color sample must not select a
// list item merely because it shares that row's y-coordinate.
if m.picker.kind == pickerTheme {
overlayWidth := minInt(width, pickerOverlayMaxWidth)
if overlayWidth < pickerOverlayMinWidth {
overlayWidth = width
}
listWidth, _, showPreview := themePickerColumnWidths(maxInt(1, overlayWidth-4))
// styledBlockFillTitle contributes a left border and one-cell inset.
if showPreview && hit.x >= listWidth+2 {
return mouseSelectionTarget{}, false
}
}
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
start := selectableListStart(len(m.picker.items), maxVisible, selected)
@@ -549,9 +531,6 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if hit.y == line {
index := start + offset
m.picker.selected = index
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
// so the theme picker repaints before the confirming second click.
m.previewSelectedTheme()
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line++
+24 -4
View File
@@ -9,7 +9,9 @@ import (
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestMouseClickSelectsThenAppliesCommandSuggestionRow(t *testing.T) {
@@ -251,6 +253,27 @@ func TestComposerMouseClickMovesCursor(t *testing.T) {
}
}
func TestComposerMouseClickMovesCursorBelowImageThumbnail(t *testing.T) {
m := mouseTestModel()
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.refreshPendingImageThumbnail()
m.input.SetValue("hello world")
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + 5
y := frame.composerRect.y + 1 + attachmentPreviewRows
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, x, y))
next := updated.(model)
if cmd != nil {
t.Fatal("composer click should not return a command")
}
if got := next.currentComposerState().cursor; got != 5 {
t.Fatalf("composer cursor below thumbnail = %d, want 5", got)
}
}
func TestComposerMouseDragSelectsCopiesAndClears(t *testing.T) {
m := mouseTestModel()
m.input.SetValue("hello world")
@@ -985,10 +1008,7 @@ func composerMousePoint(t *testing.T, m model, column int) (int, int) {
if frame.composerRect.height <= 0 {
t.Fatalf("expected visible composer rect, frame=%#v", frame)
}
contentY := 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY++
}
contentY := 1 + m.attachmentComposerPrefixRows(width)
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + column
y := frame.composerRect.y + contentY
return x, y
+55 -22
View File
@@ -22,13 +22,19 @@ type terminalOutputFile interface {
type petImageOutput struct {
output terminalOutputFile
renderer *terminalpet.ImageRenderer
renderDisabled bool
renderers []*terminalpet.ImageRenderer
renderDisabled []bool
mu sync.Mutex
}
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
return &petImageOutput{output: output, renderer: renderer}
func newPetImageOutput(output terminalOutputFile, renderers ...*terminalpet.ImageRenderer) *petImageOutput {
active := make([]*terminalpet.ImageRenderer, 0, len(renderers))
for _, renderer := range renderers {
if renderer != nil {
active = append(active, renderer)
}
}
return &petImageOutput{output: output, renderers: active, renderDisabled: make([]bool, len(active))}
}
func (o *petImageOutput) Read(value []byte) (int, error) {
@@ -39,11 +45,13 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
if leavingAltScreen && !kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return 0, err
if leavingAltScreen {
for _, renderer := range o.renderers {
if !rendererUsesKitty(renderer) {
if err := o.writeImageUpdate(renderer.Clear); err != nil {
return 0, err
}
}
}
}
if leavingAltScreen {
@@ -51,26 +59,36 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
if err != nil {
return written, err
}
if kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return written, err
for _, renderer := range o.renderers {
if rendererUsesKitty(renderer) {
if err := o.writeImageUpdate(renderer.Clear); err != nil {
return written, err
}
}
}
return written, nil
}
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
o.renderer.Invalidate()
for _, renderer := range o.renderers {
renderer.Invalidate()
}
}
var imageUpdate bytes.Buffer
if !o.renderDisabled {
if err := o.renderer.Render(&imageUpdate); err != nil {
// Companions are decorative. Disable them for this output session after
// a renderer failure instead of terminating the interactive shell.
o.renderDisabled = true
imageUpdate.Reset()
for index, renderer := range o.renderers {
if o.renderDisabled[index] {
continue
}
var rendererUpdate bytes.Buffer
if err := renderer.Render(&rendererUpdate); err != nil {
// Terminal images are decorative. Disable only the failing renderer for
// this output session so an attachment issue cannot hide a companion
// (or vice versa) or terminate the interactive shell.
o.renderDisabled[index] = true
continue
}
imageUpdate.Write(rendererUpdate.Bytes())
}
if imageUpdate.Len() == 0 {
return writeChecked(o.output, value)
@@ -120,6 +138,15 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
return written, endErr
}
func rendererUsesKitty(renderer *terminalpet.ImageRenderer) bool {
switch renderer.Support().Protocol {
case terminalpet.ImageProtocolKitty, terminalpet.ImageProtocolKittyLocalFile:
return true
default:
return false
}
}
func writeChecked(writer io.Writer, value []byte) (int, error) {
written, err := writer.Write(value)
if err == nil && written != len(value) {
@@ -180,9 +207,15 @@ func (o *petImageOutput) clearImage() error {
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
return err
}
if err := o.renderer.Clear(writer); err != nil {
return err
for _, renderer := range o.renderers {
if err := renderer.Clear(writer); err != nil {
return err
}
}
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
if len(o.renderers) == 0 {
return nil
}
ids := append([]uint32{petAmbientImageID, petPreviewImageID}, attachmentPreviewImageIDs()...)
return o.renderers[0].DeleteImages(writer, ids...)
})
}
+4 -10
View File
@@ -86,7 +86,7 @@ func (m model) handlePetsCommand(argument string) (tea.Model, tea.Cmd) {
m.petPreview = nil
m.picker = nil
m.petTickSeq++
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
}
m.petRequestedSlug = argument
localEntries, _ := m.petClient.InstalledEntries()
@@ -242,13 +242,13 @@ func (m model) installPet(slug string) (tea.Model, tea.Cmd) {
m.petName = ""
m.petAnimation = nil
m.petTickSeq++
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
}
entry, ok := m.petEntries[slug]
if !ok {
return m.appendSystemNotice(fmt.Sprintf("Pet %q is no longer in the catalog.", slug)), nil
}
m = m.appendSystemNotice("Installing " + entry.Label() + "…")
m = m.showTransientNoticeInline("Installing "+entry.Label()+"…", transientNoticeInfo)
return m, func() tea.Msg {
animation, err := m.petClient.Install(m.ctx, entry)
return petInstalledMsg{entry: entry, animation: animation, err: err}
@@ -270,7 +270,7 @@ func (m model) applyPetInstall(msg petInstalledMsg) (tea.Model, tea.Cmd) {
m.petPlaybackState = terminalpet.Idle
m.petClickAnimationIndex = 0
m.petOutcome = terminalpet.Idle
m = m.appendSystemNotice(msg.entry.Label() + " is now your terminal companion. Use /pets off to hide it.")
m = m.showTransientNoticeInline(msg.entry.Label()+" is now your terminal companion. Use /pets off to hide it.", transientNoticeSuccess)
if m.reducedMotion {
return m, nil
}
@@ -396,9 +396,6 @@ func (m model) petLayoutActive() bool {
return false
}
layoutWidth := m.width
if m.sidebarActive() {
layoutWidth = m.chatColumnWidth()
}
if !m.altScreen || layoutWidth < petImageColumns+4 || m.height < petImageRows+8 || m.subchat.active || m.transcriptDetailed {
return false
}
@@ -615,9 +612,6 @@ func resizePetCoordinate(value, oldMaximum, newMaximum, edgeThreshold int) int {
}
func (m model) petHomePosition(width, height int) (int, int) {
if m.sidebarActive() {
width = m.chatColumnWidth()
}
maxX := maxInt(0, width-petImageColumns)
maxY := maxInt(0, height-petImageRows)
return clampInt(width-petImageColumns-2, 0, maxX), clampInt(height-petImageRows-1, 0, maxY)
+4 -4
View File
@@ -614,7 +614,7 @@ func TestAmbientPetRetainsSingleColumnComposerDock(t *testing.T) {
}
}
func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
func TestAmbientPetDoesNotChangeFullWidthFooterGeometry(t *testing.T) {
m := sidebarTestModel()
m.width, m.height = 120, 34
frame := image.NewNRGBA(image.Rect(0, 0, 12, 12))
@@ -639,12 +639,12 @@ func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
}
runes := []rune(line)
dockEdge := m.chatColumnWidth() - petReservedColumns - 1
if len(runes) < m.width || runes[dockEdge] != '╮' || runes[m.chatColumnWidth()+1] != '│' {
t.Fatalf("pet changed the normal full-height sidebar geometry: %q", line)
if len(runes) < m.width || runes[dockEdge] != '╮' {
t.Fatalf("pet changed the normal full-width composer geometry: %q", line)
}
return
}
t.Fatal("sidebar composer top not found")
t.Fatal("composer top not found")
}
func TestDockedPetFollowsSidebarLayoutWithoutBecomingFreePositioned(t *testing.T) {
+51 -23
View File
@@ -522,9 +522,43 @@ func (m model) recentModelPairsForPicker() []config.RecentModelEntry {
pairs := make([]config.RecentModelEntry, 0, len(m.recentModels)+1)
pairs = append(pairs, config.RecentModelEntry{Provider: m.providerName, Model: m.modelName})
pairs = append(pairs, m.recentModels...)
for index := range pairs {
pairs[index] = m.canonicalRecentModelPair(pairs[index])
}
return config.NormalizeRecentModels(pairs)
}
// canonicalRecentModelPair collapses legacy OpenAI-prefixed model IDs for a
// ChatGPT profile. ChatGPT's Codex endpoint uses bare model IDs, while older
// hand-entered configuration could retain the equivalent openai/<id> spelling.
// Keep this picker-only so OpenAI-compatible gateway IDs remain untouched.
func (m model) canonicalRecentModelPair(pair config.RecentModelEntry) config.RecentModelEntry {
pair.Provider = strings.TrimSpace(pair.Provider)
pair.Model = strings.TrimSpace(pair.Model)
if !m.recentPairUsesChatGPT(pair.Provider) {
return pair
}
if len(pair.Model) >= len("openai/") && strings.EqualFold(pair.Model[:len("openai/")], "openai/") {
pair.Model = strings.TrimSpace(pair.Model[len("openai/"):])
}
return pair
}
func (m model) recentPairUsesChatGPT(providerName string) bool {
if profile, ok := m.savedProviderByName(providerName); ok {
if descriptor, hasDescriptor := m.descriptorForProfile(profile); hasDescriptor {
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
}
}
if !strings.EqualFold(strings.TrimSpace(providerName), strings.TrimSpace(m.providerName)) {
return false
}
if descriptor, ok := m.activeProviderDescriptor(); ok {
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
}
return providercatalog.NormalizeID(m.providerProfile.CatalogID) == "chatgpt"
}
// modelPickerRecentItem resolves one "Recent" row for a provider+model pair,
// tagging it with OwnerProvider so choosing it can switch providers (like any
// other cross-provider picker row) even when it isn't the active provider.
@@ -1008,45 +1042,39 @@ func (m model) newEffortPicker() *commandPicker {
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
// newThemePicker lists the terminal-native system theme followed by named palettes
// in one flat list. Old dark/light preferences migrate to System rather than
// appearing as choices: they would imply a terminal-canvas change that Zero
// deliberately does not make.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
for _, name := range themeModes {
item := pickerItem{Label: name, Value: name}
if name == string(themeSystem) {
item.Label = "System"
} else if entry, ok := lookupTheme(name); ok {
item.Label = entry.Label
}
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
items = append(items, item)
if name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
return &commandPicker{kind: pickerTheme, title: "Choose a theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
// pickerMoved advances the open picker's cursor by delta. Theme candidates render
// only in the picker preview; their active palette is applied only after Enter.
// Safe to call with no picker open. Callers mutate through m.picker (a pointer),
// so the value receiver is fine.
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
if m.picker == nil {
return m, nil
}
m.picker.move(delta)
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+29 -5
View File
@@ -532,8 +532,8 @@ func TestModelPickerAppliesActiveProviderCatalogModelID(t *testing.T) {
if next.modelName != "openai/gpt-4.1" {
t.Fatalf("active model = %q, want raw OpenRouter model ID", next.modelName)
}
if !transcriptContains(next.transcript, "openai/gpt-4.1 ·") {
t.Fatalf("expected model switch status, got %#v", next.transcript)
if !strings.Contains(next.transientNotice.text, "openai/gpt-4.1") {
t.Fatalf("model switch notice = %q", next.transientNotice.text)
}
}
@@ -570,6 +570,30 @@ func TestRecentModelPairsForPickerPinsActiveDedupesAndCaps(t *testing.T) {
}
}
func TestRecentModelPairsForPickerCanonicalizesChatGPTOpenAIModelAliases(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "chatgpt",
ModelName: "gpt-5.6-sol",
ProviderProfile: config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
},
RecentModels: []config.RecentModelEntry{
{Provider: "chatgpt", Model: "openai/gpt-5.6-sol"},
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
},
})
got := m.recentModelPairsForPicker()
want := []config.RecentModelEntry{
{Provider: "chatgpt", Model: "gpt-5.6-sol"},
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("recentModelPairsForPicker() = %#v, want %#v", got, want)
}
}
// The picker's cross-group de-dup must key on provider+model, not model id
// alone: the same model id can be offered by two different providers, and
// those are distinct, independently selectable rows (issue: recents should
@@ -930,8 +954,8 @@ func TestModelPickerNavigatesAndChoosesAppliesHandler(t *testing.T) {
if m.modelName != "claude-haiku-4.5" {
t.Fatalf("expected model switched to claude-haiku-4.5 via handler, got %q", m.modelName)
}
if !transcriptContains(m.transcript, "Model") {
t.Fatal("choosing should append the model handler's status text")
if !strings.Contains(m.transientNotice.text, "Model:") {
t.Fatalf("model picker notice = %q", m.transientNotice.text)
}
}
@@ -963,7 +987,7 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
}
func TestThemeCommandOpensPicker(t *testing.T) {
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
// Bare /theme opens the theme popup (contained preview on move, apply on Enter),
// like /model and /effort. Full preview/commit/cancel behavior is covered in
// theme_picker_test.go; here we just pin that the no-arg command opens it.
defer applyTheme(themeDark, true)
+7 -208
View File
@@ -1,15 +1,10 @@
// plan_panel.go renders the sticky plan panel for the Zero TUI. The panel
// surfaces the in-progress task plan produced by the update_plan tool: a
// one-line header with a live spinner and progress count, a text progress
// bar, and (while running or expanded) the per-step list with status icons
// and timings. planPanelState tracks per-step start/completion timestamps
// across the tool's full-replacement updates so durations stay stable as
// steps transition between pending, in_progress, completed, and failed.
// plan_panel.go maintains the current task-plan state for run details and
// step drill-in. The update_plan result itself renders as a transcript
// checklist, while planPanelState preserves per-step timestamps across the
// tool's full-replacement updates for the contextual detail view.
package tui
import (
"fmt"
"strings"
"time"
"github.com/Gitlawb/zero/internal/tools"
@@ -27,11 +22,10 @@ type planStep struct {
completedAt time.Time
}
// planPanelState holds the sticky plan panel's view state. It lives on the
// model and is synced from the update_plan tool's CurrentPlan() output.
// planPanelState holds the latest plan state. It lives on the model and is
// synced from the update_plan tool's CurrentPlan() output.
type planPanelState struct {
steps []planStep
expanded bool
completedAt time.Time // set once all steps reach a terminal status
startedAt time.Time // set on the first non-empty update
// frozenAt freezes the live plan clock while the agent is idle (between
@@ -41,10 +35,6 @@ type planPanelState struct {
frozenAt time.Time
}
// completedHideAfter is how long a finished plan stays pinned before the
// collapsed panel hides itself to reclaim screen space.
const completedHideAfter = 30 * time.Second
// updateFromItems syncs the planStep slice from the update_plan tool. The
// tool replaces the entire plan each call, so steps are matched by content
// to preserve start/completion timestamps. Timestamps are filled in for
@@ -128,10 +118,9 @@ func (s *planPanelState) updateFromItems(items []tools.PlanItem, now time.Time)
}
}
// clear resets all plan panel state (steps, expansion, timestamps).
// clear resets all plan state and timestamps.
func (s *planPanelState) clear() {
s.steps = nil
s.expanded = false
s.completedAt = time.Time{}
s.startedAt = time.Time{}
s.frozenAt = time.Time{}
@@ -194,18 +183,6 @@ func (s *planPanelState) completeRemaining(now time.Time) {
}
}
// visible reports whether renderPlanPanel should emit anything. A finished
// plan hides itself once completedHideAfter has elapsed, unless expanded.
func (s planPanelState) visible(now time.Time) bool {
if s.isEmpty() {
return false
}
if s.isComplete() && !s.expanded && !s.completedAt.IsZero() && now.Sub(s.completedAt) > completedHideAfter {
return false
}
return true
}
// planNow returns the clock used for live plan durations. While the agent is
// idle (no run in flight, activeRunID == 0) it freezes at the moment the last
// run ended, so an in_progress step left mid-plan when the agent yields (e.g.
@@ -218,128 +195,6 @@ func (m model) planNow() time.Time {
return m.now()
}
// renderPlanPanel renders the full sticky plan panel. It returns an empty
// string when the plan is empty or when a finished plan has been collapsed
// past completedHideAfter without being expanded.
func (m model) renderPlanPanel(width int) string {
if width < 20 {
width = 20
}
state := m.plan
now := m.planNow()
if !state.visible(now) {
return ""
}
total := len(state.steps)
done := 0
for _, step := range state.steps {
if step.status == "completed" || step.status == "failed" {
done++
}
}
elapsed := time.Duration(0)
if !state.startedAt.IsZero() {
elapsed = now.Sub(state.startedAt)
}
// The header already carries done/total and the per-step ✓/•/✗ icons convey
// progress; a separate filled █/░ bar restates it and reads heavier than the
// reference agents, so it's dropped.
header := renderPlanHeader(state, m.spinnerGlyph(), done, total, elapsed)
lines := []string{header}
showSteps := state.expanded || !state.isComplete()
if showSteps {
maxContent := width - 15
if maxContent < 4 {
maxContent = 4
}
for _, step := range state.steps {
lines = append(lines, renderPlanStepLine(step, now, maxContent))
}
}
return strings.Join(lines, "\n")
}
// renderPinnedPlanPanel renders the plan for the pinned slot above the
// composer. It returns the full panel when it fits within maxHeight, otherwise
// a one-line summary (so a long plan can't crowd out the transcript or the
// input). maxHeight <= 0 means "no budget" and always uses the summary line.
// Returns "" when the plan is not visible. Ctrl+P (expand) still forces the
// full list via m.plan.expanded, but the height budget always wins to keep the
// composer on screen.
func (m model) renderPinnedPlanPanel(width int, maxHeight int) string {
// The pinned panel is only for terminals where the context sidebar CANNOT
// host the plan (too narrow / inline mode). Whenever the two-column layout is
// available — whether the sidebar is shown OR collapsed with Ctrl+B — the
// plan's home is the sidebar, so suppress the pinned copy: a Ctrl+B hide
// should hide the plan entirely, not resurrect it above the composer. Gating
// on sidebarAvailable (not sidebarActive) covers the hidden case too. The
// hidePinnedPlan flag additionally suppresses it in the two-column chat-column
// copy. Both the view and the mouse-geometry frame call footerView, so this
// stays consistent.
if m.hidePinnedPlan || m.sidebarAvailable() {
return ""
}
if !m.plan.visible(m.now()) {
return ""
}
full := m.renderPlanPanel(width)
if full == "" {
return ""
}
if maxHeight > 0 && strings.Count(full, "\n")+1 <= maxHeight {
return full
}
return m.renderPlanSummaryLine(width)
}
// renderPlanSummaryLine renders the collapsed one-line plan summary used when
// the full panel won't fit the pinned slot: a spinner/check, the done/total
// count, and the current (or first incomplete) step, truncated to width.
func (m model) renderPlanSummaryLine(width int) string {
if width < 20 {
width = 20
}
state := m.plan
total := len(state.steps)
done := 0
current := ""
for _, step := range state.steps {
switch step.status {
case "completed", "failed":
done++
case "in_progress":
if current == "" {
current = step.content
}
}
}
if current == "" {
// No in-progress step: show the first not-yet-done step, else the first.
for _, step := range state.steps {
if step.status != "completed" && step.status != "failed" {
current = step.content
break
}
}
}
if state.isComplete() {
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN · %d/%d complete", done, total))
}
label := fmt.Sprintf("%s PLAN · %d/%d · ", m.spinnerGlyph(), done, total)
room := width - len([]rune(label)) - 1
if room < 4 {
room = 4
}
return zeroTheme.accent.Render(label + truncateStep(current, room))
}
// currentStepContent returns the content of the step the plan is "on": the
// in_progress step, else the first step not yet in a terminal status, else the
// first step. Used by the header so the title names the step actually being
@@ -361,62 +216,6 @@ func currentStepContent(steps []planStep) string {
return ""
}
// renderPlanHeader builds the single header line. While running it shows the
// live spinner, the truncated first step, the done/total count, and the
// elapsed time in the accent color; once complete it shows a green check and
// "PLAN COMPLETE".
func renderPlanHeader(state planPanelState, spinnerView string, done, total int, elapsed time.Duration) string {
// Show the step the plan is currently ON (in_progress, else first incomplete),
// not always step 1 — otherwise the header text never advances and a running
// plan reads as stuck even while the done/total count climbs.
current := ""
if total > 0 {
current = truncateStep(currentStepContent(state.steps), 40)
}
if state.isComplete() {
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN COMPLETE · %d/%d · %s", done, total, formatElapsedSeconds(elapsed)))
}
return zeroTheme.accent.Render(fmt.Sprintf("%s PLAN · %s · %d/%d · %s", spinnerView, current, done, total, formatElapsedSeconds(elapsed)))
}
// renderPlanStepLine renders one step row: an indent, a status icon, the
// (truncated) content styled by status, and a duration where applicable.
// Completed/failed steps show the startedAt→completedAt span; an in_progress
// step shows the elapsed time since it started; pending steps show no time.
func renderPlanStepLine(step planStep, now time.Time, maxContent int) string {
content := truncateStep(step.content, maxContent)
var icon, body, timeStr string
switch step.status {
case "completed":
icon = zeroTheme.green.Render("✓")
// Quiet the completed body (muted, not saturated green) so the single
// in-progress accent step is the obvious focus: past=quiet, now=bright,
// future=faint. The green ✓ icon still marks success.
body = zeroTheme.muted.Render(content)
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
case "in_progress":
icon = zeroTheme.accent.Render("•")
body = zeroTheme.accent.Render(content)
started := step.startedAt
if started.IsZero() {
started = now
}
timeStr = formatElapsedSeconds(now.Sub(started))
case "failed":
icon = zeroTheme.red.Render("✗")
body = zeroTheme.red.Render(content)
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
default: // pending
icon = zeroTheme.faint.Render("○")
body = zeroTheme.faint.Render(content)
}
line := " " + icon + " " + body
if timeStr != "" {
line += " " + zeroTheme.faint.Render(timeStr)
}
return line
}
// truncateStep caps s to max runes, appending an ellipsis when truncated.
func truncateStep(s string, max int) string {
if max <= 0 {
-186
View File
@@ -1,8 +1,6 @@
package tui
import (
"fmt"
"strings"
"testing"
"time"
@@ -112,92 +110,6 @@ func TestUpdateFromItemsDuplicateContentKeepsDistinctTimestamps(t *testing.T) {
}
}
func TestPlanPanelHeight(t *testing.T) {
var s planPanelState
now := time.Now()
// Empty plan: height 0
if h := s.height(80, now); h != 0 {
t.Errorf("empty plan height = %d, want 0", h)
}
// Running plan with 3 steps: 2 (header+bar) + 3 (steps) = 5
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
{Content: "B", Status: "in_progress"},
{Content: "C", Status: "pending"},
}, now)
if h := s.height(80, now); h != 5 {
t.Errorf("running plan height = %d, want 5", h)
}
// Completed plan collapsed: 2 (header+bar only)
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
{Content: "B", Status: "completed"},
{Content: "C", Status: "completed"},
}, now)
if h := s.height(80, now); h != 2 {
t.Errorf("completed collapsed plan height = %d, want 2", h)
}
// Expanded: 2 + 3 = 5
s.expanded = true
if h := s.height(80, now); h != 5 {
t.Errorf("expanded plan height = %d, want 5", h)
}
}
func TestPlanPanelVisibleHidesAfterTimeout(t *testing.T) {
var s planPanelState
t0 := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
s.updateFromItems([]tools.PlanItem{
{Content: "A", Status: "completed"},
}, t0)
// Visible right after completion
if !s.visible(t0.Add(10 * time.Second)) {
t.Error("plan should be visible within 30s of completion")
}
// Hidden after 30s
if s.visible(t0.Add(31 * time.Second)) {
t.Error("plan should hide after 30s of completion when not expanded")
}
// Expanded keeps it visible
s.expanded = true
if !s.visible(t0.Add(31 * time.Second)) {
t.Error("expanded plan should stay visible")
}
}
func TestPlanPanelRenderEmpty(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
if got := m.renderPlanPanel(80); got != "" {
t.Errorf("empty plan should render empty string, got %q", got)
}
}
func TestPlanPanelRenderRunning(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
m.plan.updateFromItems([]tools.PlanItem{
{Content: "Read encrypt.go", Status: "completed"},
{Content: "Fix retry loop", Status: "in_progress"},
{Content: "Run tests", Status: "pending"},
}, base)
got := m.renderPlanPanel(80)
if got == "" {
t.Fatal("expected non-empty plan panel render")
}
}
// TestPlanNowFreezeAndResume covers the idle-freeze fix: while the agent is idle
// (activeRunID == 0) and a freeze time is stamped, the plan clock is frozen so an
// in_progress step left mid-plan stops ticking; during a run it tracks live time.
@@ -228,101 +140,3 @@ func TestPlanPanelClearResetsFrozenAt(t *testing.T) {
t.Fatal("clear() must reset frozenAt so the next run starts with a live clock")
}
}
// runningPlanModel builds a model with an n-step running plan for the pinned
// panel tests.
func runningPlanModel(t *testing.T, steps int) model {
t.Helper()
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
items := make([]tools.PlanItem, steps)
for i := range items {
status := "pending"
if i == 0 {
status = "in_progress"
}
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
}
m.plan.updateFromItems(items, base)
return m
}
func TestPinnedPlanFullWhenItFits(t *testing.T) {
m := runningPlanModel(t, 3)
// 3 steps => header + 3 step lines = 4; budget 10 fits.
got := m.renderPinnedPlanPanel(80, 10)
if strings.Count(got, "\n")+1 < 4 {
t.Fatalf("expected full multi-line panel within budget, got:\n%s", got)
}
if !strings.Contains(got, "Step number 1") || !strings.Contains(got, "Step number 3") {
t.Fatalf("full panel should list every step, got:\n%s", got)
}
}
func TestPinnedPlanCollapsesToSummaryWhenTooTall(t *testing.T) {
m := runningPlanModel(t, 12)
// 12 steps would be 14 lines; budget 5 forces the one-line summary.
got := m.renderPinnedPlanPanel(80, 5)
if strings.Contains(got, "\n") {
t.Fatalf("over-budget plan should collapse to one line, got:\n%s", got)
}
if !strings.Contains(got, "PLAN") {
t.Fatalf("summary line should still show PLAN, got:\n%s", got)
}
}
func TestPinnedPlanHiddenWhenEmpty(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
if got := m.renderPinnedPlanPanel(80, 10); got != "" {
t.Fatalf("no plan => empty pinned render, got %q", got)
}
}
// TestPinnedPlanHiddenWhenSidebarToggledOff: Ctrl+B (sidebarHidden) on a wide
// alt-screen terminal hides the plan entirely — it must NOT fall back to the
// pinned panel above the composer, since the sidebar is the plan's home there.
func TestPinnedPlanHiddenWhenSidebarToggledOff(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
m.headerPrinted = true
// Real conversation so the sidebar is available (not gated by the home screen).
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
if !m.sidebarActive() {
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
}
// Sidebar shown: the plan lives in the sidebar, no pinned panel.
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Fatalf("sidebar shown: pinned plan should be suppressed, got:\n%s", got)
}
// Ctrl+B collapses the sidebar -> the plan must be hidden entirely, not pinned.
m.sidebarHidden = true
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Fatalf("Ctrl+B should hide the plan entirely, but the pinned panel showed:\n%s", got)
}
// Sidebar unavailable (no alt-screen): the pinned panel is the plan's only
// home and must still show.
if got := runningPlanModel(t, 3).renderPinnedPlanPanel(80, 10); got == "" {
t.Fatal("sidebar unavailable: the pinned plan must still show")
}
}
func TestFooterIncludesPinnedPlanAboveComposer(t *testing.T) {
m := runningPlanModel(t, 3)
m.height = 40
footer := plainRender(t, m.footerView(m.chatColumnWidth()))
planIdx := strings.Index(footer, "PLAN")
composerIdx := strings.Index(footer, "describe a task")
if planIdx < 0 {
t.Fatalf("footer should contain the pinned plan, got:\n%s", footer)
}
if composerIdx >= 0 && planIdx > composerIdx {
t.Fatalf("pinned plan should render ABOVE the composer, got:\n%s", footer)
}
}
-26
View File
@@ -120,32 +120,6 @@ func (m model) sidebarPlanSelectables(width int) []planStepHit {
return hits
}
// planStepAtMouse maps a left-click in the context sidebar to a plan step index,
// mirroring sidebarLineAtMouse's column/x gate.
func (m model) planStepAtMouse(msg tea.MouseMsg) (int, bool) {
if !m.sidebarActive() {
return 0, false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return 0, false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return 0, false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return 0, false
}
for _, hit := range m.sidebarPlanSelectables(sidebarW) {
if hit.lineOffset == y {
return hit.stepIndex, true
}
}
return 0, false
}
// planStepDetailRowID is the stable transcript id for the single plan-step
// detail card, so re-clicking toggles it instead of stacking duplicates.
const planStepDetailRowID = "plan/step-detail"
+52 -14
View File
@@ -3,28 +3,66 @@ package tui
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPlanSummaryCardBodyCollapses(t *testing.T) {
func TestRenderPlanUpdateCardUsesTranscriptChecklist(t *testing.T) {
detail := "Current Plan:\n1. [completed] Explore workspace\n2. [in_progress] Create pages\n3. [pending] Build CSS\n4. [pending] Add JS\n5. [failed] Verify"
body := planSummaryCardBody(toolBodyRequest{name: "update_plan", detail: detail})
if len(body.lines) != 1 {
t.Fatalf("expected one summary line, got %d: %#v", len(body.lines), body.lines)
got, ok := renderPlanUpdateCard(detail, 80)
if !ok {
t.Fatal("well-formed plan output should render as a checklist")
}
got := body.lines[0]
for _, want := range []string{"5 steps", "1 done", "1 in progress", "1 failed"} {
if !strings.Contains(got, want) {
t.Errorf("summary missing %q: %q", want, got)
plain := plainRender(t, got)
for _, want := range []string{"Updated Plan", "✔ Explore workspace", "□ Create pages", "□ Build CSS", "✗ Verify"} {
if !strings.Contains(plain, want) {
t.Errorf("checklist missing %q: %q", want, plain)
}
}
if strings.Contains(got, "Explore") || strings.Contains(got, "Build CSS") {
t.Errorf("summary must not re-dump the full plan body: %q", got)
if strings.Contains(plain, "Current Plan:") || strings.Contains(plain, "[in_progress]") {
t.Errorf("tool-wire format must not leak into the transcript: %q", plain)
}
}
func TestPlanSummaryFallsBackOnNonPlan(t *testing.T) {
body := planSummaryCardBody(toolBodyRequest{name: "update_plan", detail: "unexpected error text"})
if len(body.lines) == 0 {
t.Error("non-plan detail should fall back to a generic body, not collapse to nothing")
func TestRenderPlanUpdateCardLeavesMalformedOutputToGenericCard(t *testing.T) {
if _, ok := renderPlanUpdateCard("unexpected error text", 80); ok {
t.Error("malformed plan output should fall back to the generic tool card")
}
if _, ok := renderPlanUpdateCard("1. [pending] disguised diagnostic", 80); ok {
t.Error("numbered status lines without a Current Plan header must fall back to the generic card")
}
if _, ok := renderPlanUpdateCard("Current Plan:\n1. [invalid] Step", 80); ok {
t.Error("unknown plan statuses must fall back to the generic card")
}
if _, ok := renderPlanUpdateCard("Current Plan:\n1. [pending] Step\nError: persistence failed", 80); ok {
t.Error("trailing diagnostics must fall back to the generic card")
}
if _, ok := renderPlanUpdateCard("Error: persistence failed\nCurrent Plan:\n1. [pending] Step", 80); ok {
t.Error("diagnostics before Current Plan must fall back to the generic card")
}
}
func TestUpdatePlanResultRendersChecklistInTranscript(t *testing.T) {
m := limeTestModel()
row := transcriptRow{
kind: rowToolResult,
tool: "update_plan",
status: tools.StatusOK,
detail: "Current Plan:\n1. [completed] Inspect the workspace\n2. [in_progress] Add the feature\n3. [pending] Run the tests",
}
got := plainRender(t, m.renderRow(row, 72, buildRowContext([]transcriptRow{row})))
for _, want := range []string{"Updated Plan", "✔ Inspect the workspace", "□ Add the feature", "□ Run the tests"} {
if !strings.Contains(got, want) {
t.Errorf("transcript plan missing %q: %q", want, got)
}
}
}
func TestFooterDoesNotPinActivePlanAboveComposer(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.plan.steps = []planStep{{content: "Keep the composer clear", status: "in_progress"}}
footer := plainRender(t, m.footerView(80))
if strings.Contains(footer, "Keep the composer clear") || strings.Contains(footer, "Ctrl+P details") {
t.Fatalf("active plan must live in the transcript, not the footer: %q", footer)
}
}
+126
View File
@@ -0,0 +1,126 @@
package tui
import (
"regexp"
"strings"
)
// planUpdateItem is the persisted, transcript-facing form of one update_plan
// item. It is parsed from the tool result rather than the live plan state so a
// resumed session reflows exactly like the original one.
type planUpdateItem struct {
status string
content string
notes string
}
var planUpdateLinePattern = regexp.MustCompile(`^\s*\d+\.\s+\[([^\]]+)\]\s*(.+?)\s*$`)
// parsePlanUpdateItems understands the stable output contract of update_plan:
// a Current Plan header, numbered status lines, and optional indented Notes.
// It returns false for malformed output so the caller can retain the ordinary
// tool-result card instead of dropping an unexpected diagnostic.
func parsePlanUpdateItems(detail string) ([]planUpdateItem, bool) {
lines := strings.Split(strings.ReplaceAll(detail, "\r\n", "\n"), "\n")
items := make([]planUpdateItem, 0, len(lines))
foundHeader := false
for _, line := range lines {
if !foundHeader {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if trimmed == "Current Plan:" {
foundHeader = true
continue
}
return nil, false
}
if match := planUpdateLinePattern.FindStringSubmatch(line); len(match) == 3 {
status := strings.ToLower(strings.TrimSpace(match[1]))
switch status {
case "pending", "in_progress", "completed", "failed":
default:
return nil, false
}
items = append(items, planUpdateItem{
status: status,
content: strings.TrimSpace(match[2]),
})
continue
}
if strings.TrimSpace(line) == "" {
continue
}
trimmed := strings.TrimSpace(line)
if len(items) > 0 &&
(strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) &&
strings.HasPrefix(trimmed, "Notes:") {
items[len(items)-1].notes = strings.TrimSpace(strings.TrimPrefix(trimmed, "Notes:"))
continue
}
return nil, false
}
return items, foundHeader && len(items) > 0
}
// renderPlanUpdateCard mirrors the transcript-first plan layout: a quiet
// heading followed by a compact, wrapped checklist. Plans therefore remain
// where they happened in the work history instead of occupying a permanent
// footer slot above the composer.
func renderPlanUpdateCard(detail string, width int) (string, bool) {
items, ok := parsePlanUpdateItems(detail)
if !ok {
return "", false
}
if width < 8 {
width = 8
}
lines := []string{zeroTheme.faint.Render("• ") + zeroTheme.ink.Bold(true).Render("Updated Plan")}
for index, item := range items {
firstPrefix := " "
if index == 0 {
firstPrefix = " └ "
}
lines = append(lines, renderPlanUpdateItem(item, firstPrefix, width)...)
}
return strings.Join(lines, "\n"), true
}
func renderPlanUpdateItem(item planUpdateItem, prefix string, width int) []string {
marker, style := planUpdateItemStyle(item.status)
contentWidth := maxInt(1, width-len([]rune(prefix))-len([]rune(marker)))
wrapped := wrapPlainText(item.content, contentWidth)
if len(wrapped) == 0 {
wrapped = []string{"(unnamed step)"}
}
lines := make([]string, 0, len(wrapped)+1)
lines = append(lines, prefix+style.Render(marker+wrapped[0]))
continuation := strings.Repeat(" ", len([]rune(prefix))+len([]rune(marker)))
for _, line := range wrapped[1:] {
lines = append(lines, continuation+style.Render(line))
}
if note := strings.TrimSpace(item.notes); note != "" {
notePrefix := strings.Repeat(" ", len([]rune(prefix))) + " "
noteWidth := maxInt(1, width-len([]rune(notePrefix)))
for _, line := range wrapPlainText(note, noteWidth) {
lines = append(lines, notePrefix+zeroTheme.faint.Italic(true).Render(line))
}
}
return lines
}
func planUpdateItemStyle(status string) (string, interface{ Render(...string) string }) {
switch strings.ToLower(strings.TrimSpace(status)) {
case "completed":
return "✔ ", zeroTheme.faint.Strikethrough(true)
case "in_progress":
return "□ ", zeroTheme.accent.Bold(true)
case "failed":
return "✗ ", zeroTheme.red
default:
return "□ ", zeroTheme.faint
}
}
+2
View File
@@ -146,6 +146,8 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op
appendRenderCacheField(&b, strconv.FormatBool(row.final))
appendRenderCacheField(&b, strconv.Itoa(row.turnTools))
appendRenderCacheField(&b, strconv.FormatInt(int64(row.turnElapsed), 10))
appendRenderCacheField(&b, strconv.Itoa(row.attachments.images))
appendRenderCacheField(&b, strconv.Itoa(row.attachments.documents))
// The FILES selection tints this row's card border, so selecting/deselecting
// a file must miss the cache entry rendered under the other state.
appendRenderCacheField(&b, strconv.FormatBool(m.rowTouchesSelectedFile(row)))
+79 -24
View File
@@ -119,19 +119,25 @@ func buildRowContext(rows []transcriptRow) rowContext {
return rc
}
// isHiddenPlumbingTool reports whether a tool is internal mechanism the user
// never needs to see in the transcript: update_plan (the plan is surfaced live
// in the context sidebar and the clickable step detail) and tool_search (the
// on-demand loading of tool schemas — the "select:…" noise). Their cards are
// suppressed so the chat reads as a clean narrative of real work.
// isHiddenPlumbingTool reports whether a tool is an internal mechanism whose
// human-facing result is rendered elsewhere. Task/TaskOutput are represented by
// specialist cards, and tool_search only loads schemas. Their successful cards
// are suppressed so the transcript remains a narrative of the actual work.
func isHiddenPlumbingTool(name string) bool {
switch name {
case "update_plan", "tool_search":
case "Task", "TaskOutput", "tool_search":
return true
}
return false
}
// toolCallCardSuppressedInTranscript also hides an in-flight update_plan call.
// Its completed result is deliberately kept: renderPlanUpdateCard turns it into
// the durable, readable checklist in the transcript.
func toolCallCardSuppressedInTranscript(name string) bool {
return isHiddenPlumbingTool(name) || name == "update_plan"
}
// skip reports whether a row renders nothing itself: a tool call whose result
// arrived collapses into the result's card; a permission prompt that has been
// decided collapses away; silent auto-approvals collapse, while manual approval
@@ -139,16 +145,16 @@ func isHiddenPlumbingTool(name string) bool {
func (rc rowContext) skip(row transcriptRow) bool {
switch row.kind {
case rowToolCall:
// Pure-plumbing tools (the plan lives in the sidebar; tool_search just
// loads tool schemas) are mechanism the user never needs — drop their
// call and result cards so the chat stays a readable narrative of work.
if isHiddenPlumbingTool(row.tool) {
// Pure-plumbing calls do not have useful standalone content. A successful
// update_plan is the exception at result time, where it becomes a plan
// checklist rather than a generic tool card.
if toolCallCardSuppressedInTranscript(row.tool) {
return true
}
return row.id != "" && rc.resolved[rcKey(row.runID, row.id)]
case rowToolResult:
// Hide only SUCCESSFUL plumbing results; a failed update_plan/tool_search
// must still surface its error.
// Hide only successful plumbing results; failures must still surface their
// specific error even when a dedicated summary normally owns the tool.
return isHiddenPlumbingTool(row.tool) && row.status != tools.StatusError
case rowPermission:
event := row.permission
@@ -268,6 +274,11 @@ func (m model) renderRowModeUncached(row transcriptRow, width int, rc rowContext
if isInternalToolArgumentError(row) {
return ""
}
if row.tool == "update_plan" && row.status != tools.StatusError {
if card, ok := renderPlanUpdateCard(row.detail, width); ok {
return card
}
}
return renderToolResultCard(row, width, rc, opts)
case rowPermission:
return renderPermissionRow(row, width)
@@ -523,17 +534,36 @@ func splitPreservingWidth(text string, measure int) []string {
func renderUserRow(row transcriptRow, width int) string {
contentWidth := userPromptContentWidth(width)
wrapped := wrapPlainText(row.text, maxInt(1, contentWidth))
lines := make([]string, 0, len(wrapped)+1)
lines := make([]string, 0, len(wrapped)+2)
// A single plain blank line delimits the turn — no full-width painted band.
// The ▌ accent gutter alone marks it as the user's, matching the clean
// reference agents instead of a heavy chat bubble.
lines = append(lines, "")
if attachment := renderUserAttachmentSummary(row.attachments); attachment != "" {
lines = append(lines, renderUserPromptStyledLine(zeroTheme.muted.Render(attachment), contentWidth))
}
for _, line := range wrapped {
lines = append(lines, renderUserPromptStyledLine(zeroTheme.ink.Bold(true).Render(line), contentWidth))
}
return strings.Join(lines, "\n")
}
func renderUserAttachmentSummary(summary transcriptAttachmentSummary) string {
visibleImages := min(summary.images, attachmentSummaryVisibleItems)
visibleDocuments := min(summary.documents, attachmentSummaryVisibleItems)
parts := make([]string, 0, visibleImages+visibleDocuments+1)
for index := 1; index <= visibleImages; index++ {
parts = append(parts, fmt.Sprintf("[Image #%d]", index))
}
for index := 1; index <= visibleDocuments; index++ {
parts = append(parts, fmt.Sprintf("[Document #%d]", index))
}
if hidden := summary.images + summary.documents - visibleImages - visibleDocuments; hidden > 0 {
parts = append(parts, fmt.Sprintf("[+%d attachments]", hidden))
}
return strings.Join(parts, " ")
}
const userPromptPrefix = "▌ "
func userPromptContentWidth(width int) int {
@@ -1333,9 +1363,7 @@ func renderAskUserQuestionnaire(prompt pendingAskUserPrompt, input string, width
multi := len(questions) > 1
var lines []string
if header := strings.TrimSpace(prompt.request.Header); header != "" {
lines = append(lines, fill(zeroTheme.ink).Render(header))
}
lines = append(lines, renderAskUserWaitingState(strings.TrimSpace(prompt.request.Header), width, fill))
// Tab row (only for multi-question prompts): each question's short title + a
// trailing Confirm tab; the active tab is a lime badge, answered ones get a ✓.
@@ -1433,6 +1461,23 @@ func renderAskUserQuestionnaire(prompt pendingAskUserPrompt, input string, width
return styledBlockFill(width, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
}
// renderAskUserWaitingState makes the paused handoff explicit inside the prompt
// that owns the keyboard. It is intentionally steady: this is user-blocked work,
// not background progress, so a spinner would imply Zero can advance without an
// answer. The state label wins over the optional title on narrow terminals.
func renderAskUserWaitingState(title string, width int, fill func(lipgloss.Style) lipgloss.Style) string {
state := zeroTheme.accent.Render("●") + " " + fill(zeroTheme.faint).Render("waiting for your answer")
available := maxInt(1, width-4)
if title == "" {
return fitStyledLine(state, available)
}
heading := fill(zeroTheme.ink).Render(title)
if gap := available - lipgloss.Width(heading) - lipgloss.Width(state); gap >= 2 {
return heading + strings.Repeat(" ", gap) + state
}
return fitStyledLine(state, available)
}
// --- Tool cards -------------------------------------------------------------
// cardBodyMaxLines caps every card body; hidden lines collapse into a
@@ -1456,8 +1501,9 @@ type cardBody struct {
// global pending flag alone would re-animate dead cards on every later run.
func (m model) renderRunningToolCard(row transcriptRow, width int, rc rowContext, opts cardRenderOptions) string {
glyph := zeroTheme.faintest.Render("…")
if m.pending && row.runID != 0 && row.runID == m.activeRunID {
glyph = zeroTheme.accent.Render(m.spinnerGlyph())
active := m.pending && row.runID != 0 && row.runID == m.activeRunID
if active {
glyph = zeroTheme.accent.Render("›")
}
// The call row carries its own argHints; rc.hints/args only matter for
// result rows, whose detail is the tool output.
@@ -1469,9 +1515,12 @@ func (m model) renderRunningToolCard(row transcriptRow, width int, rc rowContext
if arg == "" {
arg = rc.args[rcKey(row.runID, row.id)]
}
// Running cards keep the normal name color; the accent spinner glyph at the
// front already marks them live (and orphaned dead cards must not look active).
// Running cards keep the normal name color; the static accent marker identifies
// the active operation while the turn-level status owns animation.
head := toolCardHead(toolRowName(row), hint, arg, "", "", "", true, zeroTheme.ink, rc.auto[rcKey(row.runID, row.id)], width, opts)
if active {
return renderLeftRuleCard(width, []string{glyph + " " + head}, zeroTheme.cardRun)
}
return toolCard(head, glyph, nil, "", zeroTheme.cardRun, width)
}
@@ -1759,6 +1808,12 @@ func toolCardActionLabel(name string, detail string, running bool) string {
return "Listing"
case "terminal_session":
return "Running"
case "update_plan":
return "Planning"
case "tool_search":
return "Preparing tools"
case "Task":
return "Delegating"
default:
return toolDisplayName(name)
}
@@ -1921,10 +1976,10 @@ func singleLineToolHeadText(text string) string {
return strings.Join(parts, " ")
}
// toolCard draws a compact tool block: the status glyph leads the head row,
// body lines follow directly below, and an optional footer closes the block. No
// rail or box border is drawn, so the transcript does not carry a distracting
// vertical activity line.
// toolCard draws a compact completed-or-static tool block: the status glyph
// leads the head row, body lines follow directly below, and an optional footer
// closes the block. The one live card is rendered separately with a breathing
// rail, so history remains visually quiet.
func toolCard(head string, glyph string, body []string, footer string, _ lipgloss.Style, width int) string {
if width < 24 {
width = 24
+100 -49
View File
@@ -52,21 +52,35 @@ func TestStyleAssistantMarkdownLinePassesAnsiVerbatim(t *testing.T) {
}
}
// update_plan and Task render as a dedicated UI (plan panel / specialist card),
// so their transcript tool cards are suppressed; everything else still shows.
func TestToolCardSuppressedInTranscript(t *testing.T) {
for _, name := range []string{"Task", "update_plan"} {
if !toolCardSuppressedInTranscript(name) {
t.Errorf("%q should be suppressed from the transcript (shown by a dedicated UI)", name)
// Task and TaskOutput render through specialist cards. update_plan hides only
// while in flight; its successful result is the durable transcript checklist.
func TestToolCallCardSuppressedInTranscript(t *testing.T) {
for _, name := range []string{"Task", "update_plan", "TaskOutput", "tool_search"} {
if !toolCallCardSuppressedInTranscript(name) {
t.Errorf("%q call should be suppressed from the transcript", name)
}
}
for _, name := range []string{"read_file", "write_file", "edit_file", "bash", "swarm_spawn"} {
if toolCardSuppressedInTranscript(name) {
if toolCallCardSuppressedInTranscript(name) {
t.Errorf("%q must still show its transcript card", name)
}
}
}
func TestSuppressedToolFailuresRemainVisible(t *testing.T) {
for _, name := range []string{"Task", "TaskOutput", "tool_search"} {
if !toolResultCardSuppressedInTranscript(name, tools.StatusOK) {
t.Errorf("successful %q result should be suppressed", name)
}
if toolResultCardSuppressedInTranscript(name, tools.StatusError) {
t.Errorf("failed %q result must remain visible", name)
}
}
if toolResultCardSuppressedInTranscript("update_plan", tools.StatusOK) {
t.Error("successful update_plan result must render as a transcript checklist")
}
}
func TestUserRowRendersPromptGutter(t *testing.T) {
m := limeTestModel()
row := transcriptRow{kind: rowUser, text: "add a --version flag"}
@@ -76,6 +90,64 @@ func TestUserRowRendersPromptGutter(t *testing.T) {
}
}
func TestRunningToolCardUsesLiveRailOnlyForActiveRun(t *testing.T) {
m := limeTestModel()
m.pending = true
m.activeRunID = 4
row := transcriptRow{kind: rowToolCall, id: "call-1", runID: 4, tool: "read_file", detail: "main.go"}
active := plainRender(t, m.renderRunningToolCard(row, 80, buildRowContext([]transcriptRow{row}), cardRenderOptions{}))
if !strings.HasPrefix(active, "│ ") {
t.Fatalf("active card should begin with a live rail, got %q", active)
}
if !strings.Contains(active, "Reading") {
t.Fatalf("active card should retain its action label, got %q", active)
}
m.pending = false
inactive := plainRender(t, m.renderRunningToolCard(row, 80, buildRowContext([]transcriptRow{row}), cardRenderOptions{}))
if strings.HasPrefix(inactive, "│ ") {
t.Fatalf("inactive card should not retain a live rail, got %q", inactive)
}
m.pending = true
stale := row
stale.runID = m.activeRunID + 1
staleCard := plainRender(t, m.renderRunningToolCard(stale, 80, buildRowContext([]transcriptRow{stale}), cardRenderOptions{}))
if strings.HasPrefix(staleCard, "│ ") {
t.Fatalf("a stale run card should not receive a live rail, got %q", staleCard)
}
}
func TestAskUserQuestionnaireNamesWaitingForAnswer(t *testing.T) {
prompt := pendingAskUserPrompt{
request: agent.AskUserRequest{
Header: "Quick question",
Questions: []agent.AskUserQuestion{{
Question: "Which task should Zero handle first?",
Options: []string{"Work helper"},
}},
},
states: newAskUserStates([]agent.AskUserQuestion{{
Question: "Which task should Zero handle first?",
Options: []string{"Work helper"},
}}),
}
got := plainRender(t, renderAskUserQuestionnaire(prompt, "", 80))
for _, want := range []string{"Quick question", "waiting for your answer"} {
if !strings.Contains(got, want) {
t.Fatalf("questionnaire missing %q:\n%s", want, got)
}
}
}
func TestAskUserWaitingStateFitsNarrowCardWithoutTitle(t *testing.T) {
const width = 12
got := renderAskUserWaitingState("", width, func(style lipgloss.Style) lipgloss.Style { return style })
if visible := lipgloss.Width(got); visible > width-4 {
t.Fatalf("waiting state width = %d, want at most %d: %q", visible, width-4, plainRender(t, got))
}
}
func TestTranscriptSeparatesUserPromptFromContinuation(t *testing.T) {
m := limeTestModel()
m.headerPrinted = true
@@ -1080,6 +1152,20 @@ func TestToolCardHeadCollapsesMultilineCommand(t *testing.T) {
}
}
func TestToolCardHeadStylesShellCommandButNotCommandOutput(t *testing.T) {
previous := zeroTheme
defer func() { zeroTheme = previous }()
_, zeroTheme = themeForMode("nord", true)
head := toolCardHead("bash", "gofmt -w calculator.go && go run . divide 9 2", "", "", "", "", false, zeroTheme.green, false, 120, cardRenderOptions{})
if !strings.Contains(head, "\x1b[") {
t.Fatalf("shell command head should be styled, got %q", head)
}
if plain := ansiPattern.ReplaceAllString(head, ""); !strings.Contains(plain, "Ran gofmt -w calculator.go && go run . divide 9 2") {
t.Fatalf("shell command head changed visible text to %q", plain)
}
}
func TestExecCommandCardBodyShowsSessionAndExit(t *testing.T) {
m := limeTestModel()
runningDetail := "output:\nServing HTTP on 0.0.0.0 port 8000\nsession_id: 1000\nUse write_stdin with session_id 1000 to poll, send input, or interrupt it."
@@ -1892,54 +1978,19 @@ func TestSessionsCardFieldsAreSanitized(t *testing.T) {
}
}
func TestComposerDescriptionHintRendersForSingleSlashMatch(t *testing.T) {
// When the user has typed a slash command that matches exactly one entry in
// the command palette, the composer hint line should surface that command's
// description below the box, claude-code style.
func TestCommandPaletteKeepsDescriptionInsideOverlay(t *testing.T) {
m := limeTestModel()
m.input.SetValue("/effort")
m.recomputeSuggestions()
if !m.commandPaletteOpen || len(m.suggestions) != 1 || m.suggestions[0].Name != "/effort" {
t.Fatalf("setup: expected a single /effort suggestion, got palette=%v matches=%#v", m.commandPaletteOpen, m.suggestions)
}
got := plainRender(t, m.composerDescriptionHint(96))
if !strings.Contains(got, "reasoning effort") {
t.Fatalf("description hint = %q, want it to mention reasoning effort", got)
overlay := plainRender(t, m.suggestionOverlay(96))
if !strings.Contains(overlay, "reasoning effort") {
t.Fatalf("palette description = %q", overlay)
}
}
func TestComposerDescriptionHintStaysEmptyForAmbiguousPrefix(t *testing.T) {
// A prefix that still matches multiple commands should not surface a hint --
// the dropdown is the right affordance for an ambiguous match.
m := limeTestModel()
m.input.SetValue("/")
m.recomputeSuggestions()
if !m.commandPaletteOpen || len(m.suggestions) < 2 {
t.Fatalf("setup: expected multiple suggestions for bare '/', got palette=%v matches=%d", m.commandPaletteOpen, len(m.suggestions))
}
if got := m.composerDescriptionHint(96); got != "" {
t.Fatalf("description hint should be empty for ambiguous matches, got %q", got)
}
}
func TestComposerDescriptionHintStaysEmptyAfterArgs(t *testing.T) {
// Once the user starts typing arguments, the palette narrows off and we
// shouldn't keep advertising the command's description.
m := limeTestModel()
m.input.SetValue("/effort high")
m.recomputeSuggestions()
if got := m.composerDescriptionHint(96); got != "" {
t.Fatalf("description hint should be empty after args, got %q", got)
}
}
func TestComposerDescriptionHintStaysEmptyForFilePalette(t *testing.T) {
// The @file palette already renders its rows; the description hint is
// scoped to slash commands.
m := limeTestModel()
m.input.SetValue("@")
m.recomputeSuggestions()
if got := m.composerDescriptionHint(96); got != "" {
t.Fatalf("description hint should be empty for file palette, got %q", got)
footer := plainRender(t, m.footerView(96))
if strings.Contains(footer, "reasoning effort") {
t.Fatalf("composer footer duplicated palette description: %q", footer)
}
}
+36
View File
@@ -34,3 +34,39 @@ func TestHydrationKeepsFailedTaskWithoutSpecialist(t *testing.T) {
t.Fatalf("a Task with a specialist card must NOT also show raw Task rows, got %#v", withSpecialist)
}
}
func TestHydrationPreservesSentAttachmentSummary(t *testing.T) {
rows := transcriptRowsFromSessionEvents([]sessions.Event{{
Type: sessions.EventMessage,
Payload: json.RawMessage(`{"role":"user","content":"describe this","attachments":{"images":1,"documents":2}}`),
}})
if len(rows) != 1 || rows[0].kind != rowUser {
t.Fatalf("hydrated rows = %#v, want one user row", rows)
}
if got := renderUserAttachmentSummary(rows[0].attachments); got != "[Image #1] [Document #1] [Document #2]" {
t.Fatalf("hydrated attachment summary = %q", got)
}
}
func TestHydrationCapsMalformedAttachmentCounts(t *testing.T) {
rows := transcriptRowsFromSessionEvents([]sessions.Event{{
Type: sessions.EventMessage,
Payload: json.RawMessage(`{"role":"user","content":"describe this","attachments":{"images":1000000000,"documents":1000000000}}`),
}})
if len(rows) != 1 || rows[0].attachments.images != persistedAttachmentCountLimit || rows[0].attachments.documents != persistedAttachmentCountLimit {
t.Fatalf("hydrated attachment counts = %#v, want each capped at %d", rows[0].attachments, persistedAttachmentCountLimit)
}
if got := renderUserAttachmentSummary(rows[0].attachments); got != "[Image #1] [Image #2] [Image #3] [Image #4] [Document #1] [Document #2] [Document #3] [Document #4] [+120 attachments]" {
t.Fatalf("capped attachment summary = %q", got)
}
}
func TestHydrationRejectsFractionalAttachmentCounts(t *testing.T) {
rows := transcriptRowsFromSessionEvents([]sessions.Event{{
Type: sessions.EventMessage,
Payload: json.RawMessage(`{"role":"user","content":"describe this","attachments":{"images":1.5,"documents":2.25}}`),
}})
if len(rows) != 1 || !rows[0].attachments.empty() {
t.Fatalf("fractional attachment counts must be ignored, got %#v", rows)
}
}
-107
View File
@@ -1,107 +0,0 @@
// ripple.go adds a slow cosine "breathing" colour wave for the working status
// line: each character cycles through a dim→lime ramp blended from the brand
// accent, and the wave travels across the text one character per phase tick. The
// phase is sourced from the shared spinner clock (m.spinnerPhase, advanced by the
// existing spinner.TickMsg handler) so the ripple and the braille spinner animate
// in lock-step with no extra ticker.
package tui
import (
"image/color"
"math"
"strings"
"charm.land/lipgloss/v2"
)
// rippleLevel returns a palette index in [0, travel] computed from a cosine wave
// of wavelength waveLen at integer offset distance. The wave is the standard
// "breathing" half-wave-rectified cosine:
//
// q = distance mod waveLen
// level = int( travel * (0.5 + 0.5*cos(2*Pi*q/waveLen)) )
//
// At distance == 0 the level is travel (brightest), at distance == waveLen/2 it
// is 0 (dimmest), and the cycle repeats every waveLen. A string rendered with
// rippleLevel(i+phase, ...) for i in [0, N) therefore shows a single cosine wave
// of colour that moves as phase advances.
//
// Edge cases:
// - waveLen <= 0 returns 0 (no wave; flat dimmest entry)
// - travel <= 0 returns 0 (no palette; flat dimmest entry)
// - distance < 0 mod-normalises to a non-negative value
//
// The function is intentionally pure: three ints in, an int out, depending only
// on the Go standard library math package — no model state, no rendering.
func rippleLevel(distance, travel, waveLen int) int {
if travel <= 0 || waveLen <= 0 {
return 0
}
// Normalise distance to [0, waveLen) so negative phases still behave: Go's %
// can return a negative for negative inputs.
q := distance % waveLen
if q < 0 {
q += waveLen
}
ratio := 0.5 + 0.5*math.Cos(2*math.Pi*float64(q)/float64(waveLen))
return int(float64(travel) * ratio)
}
// rippleText applies one palette style per rune of text, using rippleLevel with
// index = i+phase so the wave travels across the string one character per phase
// tick. The palette holds curated theme styles (cold-to-warm); travel = len-1 so
// the cosine spans every entry exactly once.
//
// phase advances the wave; the caller passes m.spinnerPhase so the ripple and the
// braille spinner share a single animation clock.
//
// On an empty palette or empty text the input string is returned verbatim (no
// styling) — a safe-skip contract for callers.
func rippleText(text string, palette []lipgloss.Style, phase, waveLen int) string {
if len(palette) == 0 || text == "" {
return text
}
travel := len(palette) - 1
if travel == 0 {
return palette[0].Render(text)
}
var b strings.Builder
for i, r := range text {
level := rippleLevel(i+phase, travel, waveLen)
if level < 0 {
level = 0
}
if level > travel {
level = travel
}
b.WriteString(palette[level].Render(string(r)))
}
return b.String()
}
// ripplePalette is the run-state colour ramp used by the working status line: the
// curated zeroTheme styles ordered cold (faint) at the cosine troughs to
// warm-bright (accent) at its peaks, giving a visible "the agent is thinking"
// temperature feel. Built lazily from the active theme so a /theme swap is
// reflected. No hex literal appears here — every entry is a named theme style
// (theme.go), satisfying the repo's no-hex-outside-theme rule.
func ripplePalette() []lipgloss.Style {
// A dim→lime ramp blended from the brand accent — deliberately NOT the semantic
// amber (permission) / green (success) hues. Flashing those every spinner tick
// trains the eye to ignore the exact colours that signal a real permission
// prompt or a success, so the decorative working line stays in the brand lime.
// Falls back to a coarse named ramp if the theme has no parseable accent/faint.
bright := zeroTheme.accent.GetForeground()
dim := zeroTheme.faint.GetForeground()
if bright == nil || dim == nil {
return []lipgloss.Style{zeroTheme.faint, zeroTheme.muted, zeroTheme.accent}
}
blend := lipgloss.Blend1D(5, dim, bright)
out := make([]lipgloss.Style, len(blend))
for i, c := range blend {
r, g, b, a := c.RGBA()
out[i] = lipgloss.NewStyle().Foreground(color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)})
}
return out
}
-125
View File
@@ -1,125 +0,0 @@
package tui
import (
"strings"
"testing"
)
// TestRippleLevelAtTrough verifies the cosine trough: with q = waveLen/2,
// cos(pi) = -1, so the half-wave-rectified cosine is 0 and level is 0.
func TestRippleLevelAtTrough(t *testing.T) {
if got := rippleLevel(4, 4, 8); got != 0 {
t.Fatalf("rippleLevel(4, 4, 8) = %d, want 0 (cosine trough)", got)
}
}
// TestRippleLevelAtPeak verifies the cosine peak at distance 0: cos(0) = 1, so
// level equals travel.
func TestRippleLevelAtPeak(t *testing.T) {
if got := rippleLevel(0, 4, 8); got != 4 {
t.Fatalf("rippleLevel(0, 4, 8) = %d, want 4 (cosine peak)", got)
}
}
// TestRippleLevelAtQuarterPeriod verifies the midpoint: cos(pi/2) = 0, so the
// half-wave-rectified cosine is 0.5 and level equals travel/2.
func TestRippleLevelAtQuarterPeriod(t *testing.T) {
if got := rippleLevel(2, 4, 8); got != 2 {
t.Fatalf("rippleLevel(2, 4, 8) = %d, want 2 (quarter-period midpoint)", got)
}
}
// TestRippleLevelAtThreeQuarterPeriod verifies the symmetric back-half midpoint.
// cos(3pi/2) is mathematically 0, but float rounding yields a tiny negative, and
// int() truncation floors 4*0.4999… to 1. We assert the deterministic truncated
// value rather than the ideal 2 — the wave is still a smooth dim-mid level here.
func TestRippleLevelAtThreeQuarterPeriod(t *testing.T) {
if got := rippleLevel(6, 4, 8); got != 1 {
t.Fatalf("rippleLevel(6, 4, 8) = %d, want 1 (three-quarter midpoint, truncated)", got)
}
}
// TestRippleLevelPeriodic verifies the wave repeats every waveLen.
func TestRippleLevelPeriodic(t *testing.T) {
if rippleLevel(0, 4, 8) != rippleLevel(8, 4, 8) {
t.Fatalf("wave should repeat: l(0)=%d != l(8)=%d", rippleLevel(0, 4, 8), rippleLevel(8, 4, 8))
}
if rippleLevel(2, 4, 8) != rippleLevel(10, 4, 8) {
t.Fatalf("wave should repeat: l(2)=%d != l(10)=%d", rippleLevel(2, 4, 8), rippleLevel(10, 4, 8))
}
}
// TestRippleLevelZeroInputsShortCircuits checks the safe-guard clauses.
func TestRippleLevelZeroInputsShortCircuits(t *testing.T) {
if rippleLevel(5, 4, 0) != 0 {
t.Fatalf("rippleLevel(_, _, 0) should be 0, got %d", rippleLevel(5, 4, 0))
}
if rippleLevel(5, 0, 8) != 0 {
t.Fatalf("rippleLevel(_, 0, _) should be 0, got %d", rippleLevel(5, 0, 8))
}
}
// TestRippleLevelNegativeDistanceNormalises confirms the negative-safety branch:
// distance -1 with waveLen 8 normalises to q = 7.
func TestRippleLevelNegativeDistanceNormalises(t *testing.T) {
if got, want := rippleLevel(-1, 4, 8), rippleLevel(7, 4, 8); got != want {
t.Fatalf("rippleLevel(-1, 4, 8)=%d, want value at distance=7 = %d", got, want)
}
}
// TestRippleEmptyPaletteReturnsText is the safe-skip contract.
func TestRippleEmptyPaletteReturnsText(t *testing.T) {
if got := rippleText("hello", nil, 0, 8); got != "hello" {
t.Fatalf("rippleText with empty palette = %q, want %q", got, "hello")
}
}
// TestRippleEmptyTextIsEmpty verifies the trivial input case.
func TestRippleEmptyTextIsEmpty(t *testing.T) {
if got := rippleText("", ripplePalette(), 0, 8); got != "" {
t.Fatalf("rippleText with empty input = %q, want \"\"", got)
}
}
// TestRipplePhaseShiftChangesBytesAcrossString verifies adjacent phase values
// produce different byte streams, the contract that lets the caller animate.
func TestRipplePhaseShiftChangesBytesAcrossString(t *testing.T) {
a := rippleText("Working", ripplePalette(), 0, 6)
b := rippleText("Working", ripplePalette(), 3, 6)
if a == b {
t.Fatalf("rippleText at phase 0 and 3 produced identical bytes: %q", a)
}
}
// TestRippleAdvancesPhaseAcrossFrames ties Stage 3 to the spinner clock: stepping
// phase must produce distinct outputs (the run-state text actually changes).
func TestRippleAdvancesPhaseAcrossFrames(t *testing.T) {
seen := map[string]bool{}
for phase := 0; phase < 12; phase++ {
seen[rippleText("Working", ripplePalette(), phase, 6)] = true
}
if len(seen) < 2 {
t.Fatalf("expected at least 2 distinct ripple outputs across 12 phase steps; got %d", len(seen))
}
}
// TestWorkingStatusLineRipplesWorkingWord is the integration test: the working
// status line must carry styling around "Working" (not the literal word) and the
// styling must change as the shared spinner phase advances.
func TestWorkingStatusLineRipplesWorkingWord(t *testing.T) {
m := newModel(t.Context(), Options{})
m.width = 100
m.pending = true
m.spinnerPhase = 0
a := m.workingStatusLine()
if !strings.Contains(plainRender(t, a), "Working") {
t.Fatalf("working status line missing 'Working' substring; got %q", a)
}
m.spinnerPhase = 3
b := m.workingStatusLine()
if a == b {
t.Fatalf("working status line did not change as spinnerPhase advanced; got %q", a)
}
}
+17 -2
View File
@@ -44,8 +44,17 @@ func Run(ctx context.Context, options Options) int {
// text first, keeping order intact.
options.RuntimeMessageSink = newTextCoalescer(forward).send
options.AltScreen = useAltScreen(options)
petRenderer := terminalpet.NewImageRendererWithCache(terminalpet.DetectImageSupport(os.Getenv), terminalPetFrameCache(options))
petOutput := newPetImageOutput(os.Stdout, petRenderer)
imageSupport := terminalpet.DetectImageSupport(os.Getenv)
imageCache := terminalPetFrameCache(options)
petRenderer := terminalpet.NewImageRendererWithCache(imageSupport, imageCache)
attachmentRenderers := make([]*terminalpet.ImageRenderer, attachmentPreviewMaxImages)
outputRenderers := make([]*terminalpet.ImageRenderer, 0, 1+len(attachmentRenderers))
outputRenderers = append(outputRenderers, petRenderer)
for index := range attachmentRenderers {
attachmentRenderers[index] = terminalpet.NewImageRendererWithCache(imageSupport, imageCache)
outputRenderers = append(outputRenderers, attachmentRenderers[index])
}
petOutput := newPetImageOutput(os.Stdout, outputRenderers...)
programOpts := []tea.ProgramOption{
tea.WithContext(ctx),
@@ -61,7 +70,12 @@ func Run(ctx context.Context, options Options) int {
programOpts = append(programOpts, tea.WithColorProfile(colorprofile.Ascii))
}
initialModel := newModel(ctx, options)
// Apply the terminal-native system default at the real interactive entry point.
// Constructing a model alone is also useful to tests and non-TUI helpers, and
// should not mutate the package-level render palette for those callers.
applyTheme(initialModel.themeMode, initialModel.hasDarkBg)
initialModel.petRenderer = petRenderer
initialModel.attachmentRenderers = attachmentRenderers
if initialModel.wantsMouseCapture() {
initialModel.mouseCapture = true
}
@@ -111,6 +125,7 @@ func Run(ctx context.Context, options Options) int {
}
if clearErr != nil {
fmt.Fprintln(os.Stderr, "zero: terminal companion cleanup error:", clearErr)
return 1
}
return 0
}
+65
View File
@@ -0,0 +1,65 @@
package tui
import "charm.land/lipgloss/v2"
const runDetailsMaxItems = 5
const runDetailsMinWidth = 24
// runDetailsAvailable reports whether the current session has useful state to
// inspect without taking permanent width away from the conversation.
func (m model) runDetailsAvailable() bool {
return !m.transcriptEmpty() && (m.sidebarHasContent() || m.sidebarTokenText() != "" || m.pending)
}
// runDetailsAllowed keeps the focused summary out of setup and selection flows
// that already own the keyboard.
func (m model) runDetailsAllowed() bool {
return m.altScreen && m.height > 0 && m.width >= runDetailsMinWidth && !m.subchat.active && !m.transcriptEmpty() &&
!m.setup.visible && m.providerWizard == nil && m.mcpAddWizard == nil &&
m.mcpManager == nil && m.picker == nil && m.renamePrompt == nil && !m.suggestionsActive()
}
func (m model) runDetailsOverlay(width int) string {
if !m.runDetailsOpen || width < runDetailsMinWidth || !m.runDetailsAllowed() {
return ""
}
overlayWidth := minInt(72, maxInt(40, width-8))
overlayWidth = minInt(overlayWidth, width)
inner := maxInt(12, overlayWidth-4)
lines := m.runDetailsLines(inner)
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, "Run details", lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
}
func (m model) runDetailsLines(width int) []string {
lines := make([]string, 0, 16)
appendSection := func(header string, rows []string) {
if len(rows) == 0 {
return
}
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, header)
if len(rows) > runDetailsMaxItems {
rows = append(append([]string(nil), rows[:runDetailsMaxItems-1]...), " "+zeroTheme.faint.Render("… more in transcript"))
}
lines = append(lines, rows...)
}
appendSection(m.sidebarAgentHeader(width), m.sidebarAgentLines(width))
appendSection(m.sidebarPlanHeader(width), m.sidebarPlanLines(width))
fileLines, _ := m.sidebarFileLines(width)
appendSection(m.sidebarFilesHeader(width), fileLines)
appendSection(sidebarHeader("ACTIVITY", width), m.sidebarActivityLines(width, runDetailsMaxItems))
if tokens := m.sidebarTokenText(); tokens != "" {
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, zeroTheme.faint.Render(tokens))
}
if len(lines) == 0 {
return []string{zeroTheme.faint.Render("No active run details yet.")}
}
lines = append(lines, "", zeroTheme.faint.Render("Esc or Ctrl+B closes"))
return lines
}
+66
View File
@@ -0,0 +1,66 @@
package tui
import (
"strings"
"testing"
"charm.land/bubbletea/v2"
)
func TestRunDetailsOverlaySurfacesCurrentState(t *testing.T) {
m := sidebarTestModel()
m.runDetailsOpen = true
plain := plainRender(t, m.runDetailsOverlay(m.width))
for _, want := range []string{"Run details", "PLAN", "wire it up", "Esc or Ctrl+B closes"} {
if !strings.Contains(plain, want) {
t.Fatalf("run details missing %q:\n%s", want, plain)
}
}
}
func TestRunDetailsTogglePreservesFullWidthTranscript(t *testing.T) {
m := sidebarTestModel()
if got, want := m.chatColumnWidth(), chatWidth(m.width); got != want {
t.Fatalf("chat width before details = %d, want %d", got, want)
}
updated, _ := m.Update(testKeyCtrl('b'))
opened := updated.(model)
if !opened.runDetailsOpen {
t.Fatal("Ctrl+B should open run details when the composer is empty")
}
if got, want := opened.chatColumnWidth(), chatWidth(opened.width); got != want {
t.Fatalf("chat width with details = %d, want %d", got, want)
}
updated, _ = opened.Update(testKeyCtrl('b'))
closed := updated.(model)
if closed.runDetailsOpen {
t.Fatal("Ctrl+B should close run details")
}
}
func TestRunDetailsSwallowsComposerInputUntilClosed(t *testing.T) {
m := sidebarTestModel()
m.runDetailsOpen = true
updated, _ := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})
if got := updated.(model).composerValue(); got != "" {
t.Fatalf("input behind run details = %q, want empty", got)
}
}
func TestRunDetailsClosesWhenTerminalBecomesTooNarrow(t *testing.T) {
m := sidebarTestModel()
m.runDetailsOpen = true
updated, _ := m.Update(tea.WindowSizeMsg{Width: runDetailsMinWidth - 1, Height: m.height})
if updated.(model).runDetailsOpen {
t.Fatal("narrow resize should close an unavailable run-details overlay")
}
}
func TestRunDetailsOverlayHidesBehindPicker(t *testing.T) {
m := sidebarTestModel()
m.runDetailsOpen = true
m.picker = &commandPicker{}
if got := m.runDetailsOverlay(m.width); got != "" {
t.Fatalf("run details must yield to a picker that owns the keyboard, got:\n%s", got)
}
}
+30 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"path/filepath"
"runtime"
"strings"
@@ -95,6 +96,7 @@ func (m model) startNewSession() model {
// documents, or queued text.
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
m.pendingDocuments = nil
m.queuedMessage = ""
// The remembered /retry attachment snapshot belongs to the previous session
@@ -582,7 +584,7 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
}
switch role {
case "user":
rows = append(rows, transcriptRow{kind: rowUser, text: content})
rows = append(rows, transcriptRow{kind: rowUser, text: content, attachments: attachmentSummaryFromPayload(payload)})
case "assistant":
// A persisted assistant message was a turn's final answer. Tool/timing
// counters were not recorded; the completion line omits those segments.
@@ -640,13 +642,17 @@ func transcriptRowsFromSessionEvents(events []sessions.Event) []transcriptRow {
status = tools.StatusOK
}
output := payloadString(payload, "output")
detail := payloadString(payload, "displayPreview")
if detail == "" {
detail = output
}
rows = append(rows, transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(id, callSeq[id]),
text: fmt.Sprintf("tool result: %s %s %s", name, status, truncateTUIOutput(output, tuiToolOutputLimit)),
tool: name,
status: status,
detail: output,
detail: detail,
meta: payloadStringMap(payload, "meta"),
changedFiles: payloadStringSlice(payload, "changedFiles"),
changeSummaries: payloadExecutionChanges(payload, "changeSummaries"),
@@ -886,6 +892,28 @@ func payloadBool(payload map[string]any, key string) bool {
}
}
func attachmentSummaryFromPayload(payload map[string]any) transcriptAttachmentSummary {
attachments, ok := payloadMap(payload, "attachments")
if !ok {
return transcriptAttachmentSummary{}
}
return transcriptAttachmentSummary{
images: payloadNonNegativeInt(attachments, "images"),
documents: payloadNonNegativeInt(attachments, "documents"),
}
}
func payloadNonNegativeInt(payload map[string]any, key string) int {
value, ok := payload[key].(float64)
if !ok || value <= 0 || value != math.Trunc(value) {
return 0
}
if value > persistedAttachmentCountLimit {
return persistedAttachmentCountLimit
}
return int(value)
}
func payloadMap(payload map[string]any, key string) (map[string]any, bool) {
value, ok := payload[key].(map[string]any)
return value, ok
+11 -25
View File
@@ -55,8 +55,8 @@ func TestEffortCommandListsAndSetsSupportedEffort(t *testing.T) {
if next.reasoningEffort != modelregistry.ReasoningEffortHigh {
t.Fatalf("expected effort high, got %q", next.reasoningEffort)
}
if !transcriptContains(next.transcript, "active effort: high") {
t.Fatalf("expected effort switch transcript, got %#v", next.transcript)
if next.transientNotice.text != "Reasoning effort: high" {
t.Fatalf("effort switch notice = %q", next.transientNotice.text)
}
}
@@ -162,8 +162,8 @@ func TestStyleCommandListsAndSetsSessionPreference(t *testing.T) {
if next.responseStyle != "explanatory" {
t.Fatalf("expected explanatory style, got %q", next.responseStyle)
}
if !transcriptContains(next.transcript, "active style: explanatory") {
t.Fatalf("expected style switch transcript, got %#v", next.transcript)
if next.transientNotice.text != "Style: explanatory" {
t.Fatalf("style switch notice = %q", next.transientNotice.text)
}
}
@@ -839,31 +839,17 @@ func TestUnpricedUsageStatusUsesLatestEventNotCumulative(t *testing.T) {
}
}
func TestStatusLineDropsTokenFigureWhenSidebarShowsIt(t *testing.T) {
func TestStatusLineShowsTokenFigureWithoutPersistentRail(t *testing.T) {
m := sidebarTestModel()
m, _ = m.recordUsageEvent("test-model", zeroruntime.Usage{InputTokens: 100, OutputTokens: 20})
if !m.sidebarActive() {
t.Fatal("expected the sidebar to be active for this model")
}
// The sidebar owns the token readout at its floor.
// Run details retains the full token count on demand.
if got := m.sidebarTokenText(); !strings.Contains(got, "120 tokens") {
t.Fatalf("sidebar token text = %q, want it to carry the 120-token figure", got)
}
// With the sidebar open, the status line must not repeat the token figure.
// The status line is the always-visible token readout.
status := plainRender(t, m.statusLine(m.width))
if strings.Contains(status, "tok") {
t.Fatalf("status line = %q, should not duplicate the token figure while the sidebar shows it", status)
}
// Sidebar hidden → the status line is the only home for the figure again.
m.sidebarHidden = true
if m.sidebarActive() {
t.Fatal("sidebar should be inactive once hidden")
}
hidden := plainRender(t, m.statusLine(m.width))
if !strings.Contains(hidden, "120 tok") {
t.Fatalf("status line with sidebar hidden = %q, want the token figure back", hidden)
if !strings.Contains(status, "120 tok") {
t.Fatalf("status line = %q, want the token figure", status)
}
}
@@ -938,8 +924,8 @@ func TestModelSwitchClearsUnsupportedEffortPreference(t *testing.T) {
if next.reasoningEffort != "" {
t.Fatalf("expected unsupported effort preference to reset, got %q", next.reasoningEffort)
}
if !transcriptContains(next.transcript, "effort auto (reset)") {
t.Fatalf("expected model switch transcript to mention effort reset, got %#v", next.transcript)
if !strings.Contains(next.transientNotice.text, "effort auto") {
t.Fatalf("expected model switch notice to mention effort reset, got %q", next.transientNotice.text)
}
}
+19 -72
View File
@@ -1,12 +1,5 @@
// sidebar.go renders the right-hand context sidebar for the two-column chat
// layout (alt-screen managed mode only). The sidebar surfaces three sections —
// the spawned AGENTS and their live working detail, the live PLAN (the same data
// the pinned plan panel reads), and a token/context readout at the bottom — so
// the chat column stays focused on the conversation. It is a set of pure
// helpers: the layout in
// transcriptView renders the chat at a reduced width via the existing scroll
// engine, builds a sidebar block of the same height here, and joins the two
// columns row-by-row through joinColumns.
// sidebar.go retains compact data-formatting helpers used by the run-details
// surface. Conversation rendering is single-column.
package tui
import (
@@ -41,40 +34,13 @@ func sidebarWidth(total int) int {
return clamp(total*30/100, sidebarMinWidth, sidebarMaxWidth)
}
// sidebarActive reports whether the two-column layout should render right now:
// the sidebar is available AND the user hasn't collapsed it with Ctrl+B.
// sidebarActive is retained for the compact data helpers and legacy file-detail
// interactions. The persistent two-column layout is intentionally no longer
// rendered by transcriptView.
func (m model) sidebarActive() bool {
return !m.sidebarHidden && m.sidebarAvailable()
}
// sidebarToggleAllowed reports whether the toggle-sidebar keybinding should
// respond. Unlike sidebarAvailable it OMITS the content check
// (sidebarHasContent) so the user can toggle their show/hide preference even
// when the sidebar auto-hid due to having nothing to show. The content gate
// is still applied at render time (sidebarActive chains sidebarAvailable), so
// toggling on when there's no content just records the preference for when
// content arrives — the sidebar stays hidden until then.
func (m model) sidebarToggleAllowed() bool {
if !m.altScreen || m.height <= 0 || m.subchat.active {
return false
}
if sidebarWidth(m.width) <= 0 {
return false
}
if widthTier(m.width) < tierMedium {
return false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.renamePrompt != nil || m.suggestionsActive() {
return false
}
// Home/welcome screen: stay single-column until there's real conversation.
if m.transcriptEmpty() {
return false
}
return true
}
// sidebarAvailable reports whether the two-column layout CAN render: only in
// alt-screen managed mode, with a measured height, on a wide-enough terminal,
// outside subchat/overlays, with real conversation. It ignores the user's Ctrl+B
@@ -147,17 +113,10 @@ func (m model) sidebarHasContent() bool {
return !m.plan.isEmpty()
}
// chatColumnWidth is the chat's render width: the full chat width normally, and
// the reduced left-column width when the two-column layout is active (total
// minus the sidebar and the 1-cell divider). All frame/geometry callers route
// through this so the rendered chat, the scroll engine, and mouse hit-testing
// agree on where the chat column ends.
// chatColumnWidth is the full conversation width. All frame and geometry
// callers route through this so transcript, composer, and mouse hit-testing
// share one layout.
func (m model) chatColumnWidth() int {
if sw := m.sidebarWidthForLayout(); sw > 0 {
// Reserve 3 cells for the padded " │ " divider (a cell of air on each side
// of the rule) — see joinColumns.
return chatWidth(m.width - sw - 3)
}
return chatWidth(m.width)
}
@@ -177,15 +136,6 @@ func transcriptContentWidth(columnWidth int) int {
return cw
}
// sidebarWidthForLayout returns the active sidebar column width, or 0 when the
// two-column layout is not active.
func (m model) sidebarWidthForLayout() int {
if !m.sidebarActive() {
return 0
}
return sidebarWidth(m.width)
}
// sidebarSpecialists returns the specialist delegations worth surfacing in the
// AGENTS panel, EXCLUDING failed tool-misroutes: when a model calls a swarm (or
// any other) tool name as if it were a specialist, the lookup fails with
@@ -210,15 +160,9 @@ func (m model) sidebarSpecialists() []specialistInfo {
return out
}
// sidebarHasAgents reports whether the two-column sidebar is active AND has at
// least one agent line to animate (a specialist delegation or a swarm member).
// The spinner tick keeps firing while this holds so the cool swarm ripple on
// member names stays alive even when no run is in flight; gating it on the
// sidebar+agent presence means a plain idle session schedules no timer.
// sidebarHasAgents reports whether the session has agent activity worth keeping
// alive for the compact run-details surface.
func (m model) sidebarHasAgents() bool {
if !m.sidebarActive() {
return false
}
return len(m.sidebarSpecialists())+len(m.swarmSpawnedAgents()) > 0
}
@@ -808,11 +752,12 @@ const maxSidebarActivityLines = 5
// feed inspects per render, so a sparse-work transcript stays O(window).
const maxSidebarActivityScan = 200
// sidebarActivityLines builds the ACTIVITY feed: a live "generating…" pulse (when
// the run has gone quiet) atop the most recent completed work (files written,
// commands run). It scans the transcript BACKWARD and stops after the cap, so the
// cost is bounded by the cap — never a full-transcript walk per frame. budget is
// the rows available before the token floor; the feed clips itself to it.
// sidebarActivityLines builds the ACTIVITY feed: a live, phase-aware pulse atop
// the most recent completed work (files written, commands run). The quiet-run
// warning still replaces the phase label when needed. It scans the transcript
// BACKWARD and stops after the cap, so the cost is bounded by the cap — never a
// full-transcript walk per frame. budget is the rows available before the token
// floor; the feed clips itself to it.
func (m model) sidebarActivityLines(width, budget int) []string {
if budget <= 0 {
return nil
@@ -838,9 +783,11 @@ func (m model) sidebarActivityLines(width, budget int) []string {
}
live := ""
if m.activeRunID != 0 {
label := m.workingActivity()
if hint := m.quietGenerationHint(); hint != "" {
live = " " + zeroTheme.accent.Render("•") + " " + zeroTheme.faint.Render(truncateStep(hint, room))
label = hint
}
live = " " + zeroTheme.accent.Render("›") + " " + zeroTheme.faint.Render(truncateStep(label, room))
}
lines := make([]string, 0, len(work)+1)
if live != "" {
+48 -99
View File
@@ -52,6 +52,18 @@ func TestSidebarActivityLines(t *testing.T) {
}
}
func TestSidebarActivityNamesCurrentTool(t *testing.T) {
m := sidebarTestModel()
m.pending = true
m.activeRunID = 7
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, id: "call-1", runID: 7, tool: "grep", detail: "internal/tui"})
got := plainRender(t, strings.Join(m.sidebarActivityLines(40, 10), "\n"))
if !strings.Contains(got, "searching") {
t.Fatalf("sidebar activity should name the active tool, got:\n%s", got)
}
}
func swarmSidebarTestModel(t *testing.T, sessionIDs map[string]string) model {
t.Helper()
m := sidebarTestModel()
@@ -94,31 +106,8 @@ func TestSidebarAgentSelectablesMapToScreenRows(t *testing.T) {
}
}
func TestSidebarLineAtMouseHitsMemberRow(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
// Sidebar starts at screen X = chatColumnWidth + 3 (the " │ " divider); the
// first member row is at sidebar line 1 → screen Y 1.
x := m.chatColumnWidth() + 3 + 2
hit, ok := m.sidebarLineAtMouse(testMouseClick(tea.MouseLeft, x, 1))
if !ok || hit.sessionID != "sess-1" {
t.Fatalf("expected to hit member row (sess-1), got ok=%v hit=%+v", ok, hit)
}
// A click in the chat column (left of the divider) must miss the sidebar.
if _, ok := m.sidebarLineAtMouse(testMouseClick(tea.MouseLeft, 2, 1)); ok {
t.Fatal("a click in the chat column should not hit the sidebar")
}
// The AGENTS header row (Y 0) is not a clickable member.
if _, ok := m.sidebarLineAtMouse(testMouseClick(tea.MouseLeft, x, 0)); ok {
t.Fatal("the AGENTS header row should not be clickable")
}
// A member with no known session (subagent-2 at Y 2) is not clickable.
if _, ok := m.sidebarLineAtMouse(testMouseClick(tea.MouseLeft, x, 2)); ok {
t.Fatal("a member without a session id should not be clickable")
}
}
func TestSidebarMemberClickRoutesToSubchatDrillIn(t *testing.T) {
// A real session so the click can actually drill in (not just be "handled").
func TestSidebarMemberClickDoesNotInterceptFullWidthChat(t *testing.T) {
// A real member session proves that a stale rail coordinate cannot open it.
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{Title: "member: build the homepage", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
@@ -133,15 +122,13 @@ func TestSidebarMemberClickRoutesToSubchatDrillIn(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": session.SessionID})
m.sessionStore = store
x := m.chatColumnWidth() + 3 + 2
x := m.width - sidebarWidth(m.width) + 2
next, _, handled := m.handleTranscriptSelectionMouse(testMouseClick(tea.MouseLeft, x, 1))
if !handled {
t.Fatal("clicking a clickable member row should be handled")
if handled {
t.Fatal("an invisible rail coordinate must not be handled")
}
// It must actually enter the member's subchat session, not merely consume the click.
if !next.subchat.active || next.subchat.childSessionID != session.SessionID {
t.Fatalf("click should drill into member session %q, got active=%v id=%q",
session.SessionID, next.subchat.active, next.subchat.childSessionID)
if next.subchat.active {
t.Fatalf("an invisible rail coordinate must not enter member session %q", session.SessionID)
}
}
@@ -251,13 +238,10 @@ func TestSidebarToggleHidesAndShows(t *testing.T) {
}
}
func TestChatColumnWidthLeavesRoomForSidebar(t *testing.T) {
func TestChatColumnWidthUsesFullConversationWidth(t *testing.T) {
m := sidebarTestModel()
chatW := m.chatColumnWidth()
sidebarW := sidebarWidth(m.width)
if chatW+3+sidebarW != m.width {
t.Fatalf("chat(%d) + divider(3) + sidebar(%d) = %d, want total width %d",
chatW, sidebarW, chatW+3+sidebarW, m.width)
if got, want := m.chatColumnWidth(), chatWidth(m.width); got != want {
t.Fatalf("chat width = %d, want full width %d", got, want)
}
// When the sidebar is inactive, chat width is the full chat width.
@@ -613,52 +597,52 @@ func TestJoinColumnsAligns(t *testing.T) {
}
}
func TestTwoColumnTranscriptViewWidth(t *testing.T) {
func TestTranscriptViewUsesFullWidth(t *testing.T) {
m := sidebarTestModel()
out := m.twoColumnTranscriptView()
out := m.transcriptView()
lines := strings.Split(out, "\n")
if len(lines) != m.height {
t.Fatalf("two-column view = %d lines, want terminal height %d", len(lines), m.height)
t.Fatalf("transcript view = %d lines, want terminal height %d", len(lines), m.height)
}
for i, line := range lines {
if w := lipgloss.Width(line); w != m.width {
t.Fatalf("two-column row %d width = %d, want full width %d", i, w, m.width)
if w := lipgloss.Width(line); w > m.width {
t.Fatalf("row %d width = %d, exceeds full width %d", i, w, m.width)
}
}
for _, line := range lines {
if strings.HasPrefix(ansiPattern.ReplaceAllString(line, ""), "╭") && lipgloss.Width(line) == m.width {
return
}
}
t.Fatal("full-width composer frame not found")
}
func TestTwoColumnSidebarRemainsFullHeightBesideFooter(t *testing.T) {
func TestFullWidthFooterKeepsContextAndComposerVisible(t *testing.T) {
m := sidebarTestModel()
m.width, m.height = 120, 34
m.unpricedTokens = 10000
out := plainRender(t, m.twoColumnTranscriptView())
out := plainRender(t, m.transcriptView())
lines := strings.Split(out, "\n")
tokenRow := -1
composerTop := -1
for index, line := range lines {
if strings.Contains(line, "10K tokens") {
if strings.Contains(line, "10K tok") {
tokenRow = index
}
if strings.HasPrefix(line, "╭") {
composerTop = index
}
}
if tokenRow != len(lines)-1 {
t.Fatalf("sidebar token summary should remain pinned to the bottom, row=%d last=%d\n%s", tokenRow, len(lines)-1, out)
if tokenRow < 0 {
t.Fatalf("footer should retain context information:\n%s", out)
}
if composerTop < 0 {
t.Fatalf("chat composer missing:\n%s", out)
}
composerRunes := []rune(lines[composerTop])
if len(composerRunes) != m.width || composerRunes[m.chatColumnWidth()-1] != '╮' {
t.Fatalf("composer should retain the chat-column width, got %q", lines[composerTop])
}
for index, line := range lines {
runes := []rune(line)
if len(runes) != m.width || runes[m.chatColumnWidth()+1] != '│' {
t.Fatalf("sidebar divider ended early on row %d: %q", index, line)
}
if len(composerRunes) != m.width || composerRunes[m.width-1] != '╮' {
t.Fatalf("composer should use the full conversation width, got %q", lines[composerTop])
}
}
@@ -667,57 +651,22 @@ func stripSidebar(lines []string) string {
return ansiPattern.ReplaceAllString(strings.Join(lines, "\n"), "")
}
// The `/` command palette must NOT collapse the sidebar. It is a centred box
// capped at suggestionPaletteMaxWidth floating over the chat column, not a
// full-screen overlay — suppressing the second column for it dropped the plan
// out of the sidebar and re-rendered it inline at the bottom on every `/`,
// which is at its most disruptive mid-run with a live plan on screen. The
// genuinely full-width overlays must still suppress it.
func TestSidebarSurvivesCommandPalette(t *testing.T) {
base := func() model {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
m.headerPrinted = true
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
return m
}
m := base()
if !m.sidebarActive() {
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
}
// `/` palette open: sidebar stays, so the plan keeps its home and the layout
// does not reflow.
// The `/` command palette must not reduce the conversation width.
func TestCommandPaletteKeepsConversationWidth(t *testing.T) {
m := sidebarTestModel()
m.altScreen = true
m.height = 40
m.headerPrinted = true
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
m.suggestions = []commandSuggestion{{Name: "/model", Desc: "Pick a model."}, {Name: "/plan", Desc: "Show planning mode status."}}
if !m.suggestionsActive() {
t.Fatal("precondition: suggestions should be active")
}
if !m.sidebarActive() {
t.Error("command palette must not collapse the sidebar")
}
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
t.Errorf("plan must stay in the sidebar, not fall back to the pinned panel:\n%s", got)
}
// The palette never contends for the sidebar's cells: the two-column path
// renders it at width = chatColumnWidth, so it is centred inside the chat
// column and cannot overlap the sidebar.
// The palette is bounded within the full-width transcript.
chatW := m.chatColumnWidth()
for _, line := range strings.Split(plainRender(t, m.suggestionOverlay(chatW)), "\n") {
if w := lipgloss.Width(line); w > chatW {
t.Errorf("palette line is %d wide, wider than the chat column %d — it would overlap the sidebar", w, chatW)
}
}
// Clicks still belong to the palette, not the sidebar rows beneath it.
if _, ok := m.sidebarLineAtMouse(tea.MouseClickMsg{Button: tea.MouseLeft}); ok {
t.Error("sidebar must not take mouse hits while the palette is open")
}
// A genuinely full-width overlay still suppresses the sidebar.
full := base()
full.picker = &commandPicker{}
if full.sidebarActive() {
t.Error("a full-screen picker must still collapse the sidebar")
}
}
+1
View File
@@ -64,6 +64,7 @@ func (m model) handleSpecCommand(task string) (tea.Model, tea.Cmd) {
}
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
specRegistry := cloneToolRegistry(m.registry)
specmode.RegisterDraftTools(specRegistry, m.cwd, m.now)
+9 -1
View File
@@ -61,7 +61,8 @@ const (
streamingFadeDuration = 1200 * time.Millisecond
// streamingFadeTickInterval is the cadence at which we re-render the
// fading text. Independent of the 80ms spinner tick — a slower cadence
// fading text. Independent of the active-status animation tick — a slower
// cadence
// is enough for a smooth-feeling fade and keeps the per-frame work
// cheap.
streamingFadeTickInterval = 150 * time.Millisecond
@@ -267,6 +268,13 @@ func (m *model) resetStreamingFade() {
// returned unchanged so live colors match committed colors instead of snapping at
// turn end.
func (m model) styleStreamingLine(line string, visualIndex, visualCount int) string {
// Inline markdown uses private SGR markers while it is being parsed. They
// include reverse-video for code spans, which must be converted to the
// palette-aware final style before the line reaches the terminal. Otherwise
// a streaming frame briefly flashes the terminal's reverse background.
if hasMarkdownDisplayControls(line) {
return styleAssistantMarkdownLine(line, zeroTheme.ink)
}
if strings.Contains(line, "\x1b") {
return line
}
+4 -2
View File
@@ -125,6 +125,8 @@ func (m model) streamingToolCallView(width int) string {
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")
// The turn-level Working line owns the only animated activity indicator.
// Keep this live tool label stable so it describes the operation without
// competing with the global liveness signal.
return renderLeftRuleCard(width, []string{head}, zeroTheme.cardRun)
}
+3 -3
View File
@@ -68,7 +68,7 @@ func (m model) handleSTTKeyPromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.finalizeSTTModelFromKey(p, "Kept your saved "+p.label+" API key. ")
}
m.sttKeyPrompt = nil
return m.appendSystemNotice("Cancelled — no " + p.label + " API key saved."), nil
return m.showTransientNoticeInline("Cancelled — no "+p.label+" API key saved.", transientNoticeInfo), nil
case keyIs(msg, tea.KeyEnter):
return m.submitSTTKey()
case keyBackspace(msg):
@@ -97,7 +97,7 @@ func (m model) submitSTTKey() (tea.Model, tea.Cmd) {
return m.finalizeSTTModelFromKey(p, "Kept your saved "+p.label+" API key. ")
}
m.sttKeyPrompt = nil
return m.appendSystemNotice("No key entered — nothing saved."), nil
return m.showTransientNoticeInline("No key entered — nothing saved.", transientNoticeInfo), nil
}
if m.dictation.saveKey != nil {
if err := m.dictation.saveKey(p.provider, key); err != nil {
@@ -123,7 +123,7 @@ func (m model) finalizeSTTModelFromKey(p *sttKeyPromptState, prefix string) (tea
if modelID != "" {
label += " " + modelID
}
return m.appendSystemNotice(prefix + "Dictation model set to " + label + ". Run /voice, then hold Space to dictate."), nil
return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice, then hold Space to dictate.", transientNoticeSuccess), nil
}
// sttKeyPromptOverlay renders the API-key prompt as a centered modal, matching
+4 -3
View File
@@ -1,6 +1,7 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
@@ -76,7 +77,7 @@ func TestCloudKeyPromptOptionalWhenAuthenticated(t *testing.T) {
if got.dictation.cfg.Provider != config.STTProviderGroq || got.dictation.cfg.Model != "whisper-large-v3-turbo" {
t.Errorf("model not applied on keep: %q/%q", got.dictation.cfg.Provider, got.dictation.cfg.Model)
}
if !transcriptHasText(got, "Kept your saved Groq API key") {
if !strings.Contains(got.transientNotice.text, "Kept your saved Groq API key") {
t.Error("expected a kept-key notice")
}
}
@@ -103,7 +104,7 @@ func TestCloudKeyPromptOptionalReplaceSavesNewKey(t *testing.T) {
if got.dictation.cfg.Model != "whisper-large-v3-turbo" {
t.Errorf("model not applied after replace: %q", got.dictation.cfg.Model)
}
if !transcriptHasText(got, "Saved your Groq API key") {
if !strings.Contains(got.transientNotice.text, "Saved your Groq API key") {
t.Error("expected a saved-key notice")
}
}
@@ -126,7 +127,7 @@ func TestCloudKeySavedAndModelApplied(t *testing.T) {
if got.dictation.cfg.Model != "whisper-large-v3-turbo" {
t.Errorf("model not applied: %q", got.dictation.cfg.Model)
}
if !transcriptHasText(got, "Saved your Groq API key") {
if !strings.Contains(got.transientNotice.text, "Saved your Groq API key") {
t.Error("expected a saved-key notice")
}
}
+1 -1
View File
@@ -373,7 +373,7 @@ func (m model) applyInstalledModel(v dictation.ModelVariant) (model, tea.Cmd) {
if aerr != nil {
return m.appendSystemNotice("Couldn't save the config: " + aerr.Error()), nil
}
return m.appendSystemNotice(v.Label + " is already downloaded — dictation ready. Run /voice, then hold Space to dictate."), nil
return m.showTransientNoticeInline(v.Label+" is ready. Run /voice to start dictating.", transientNoticeSuccess), nil
}
// sttDownloadVariants returns the variants currently offered — the full fetched
+8 -11
View File
@@ -7,30 +7,27 @@ import (
tea "charm.land/bubbletea/v2"
)
// The subchat drill-in (viewing a subagent/swarm child session) swaps the on-screen
// transcript to m.subchat.childRows, but mouse hit-testing and the footer's pinned
// plan panel were never made subchat-aware — they kept operating on/showing the
// PARENT run's state while the child session was on screen. These tests guard the
// fix: mouse selection must resolve against the child rows, the plan panel must not
// show in that view, and a selection in progress must extend across a wheel-scroll
// instead of freezing at whatever was visible when the drag started.
// The subchat drill-in (viewing a subagent/swarm child session) swaps the
// on-screen transcript to m.subchat.childRows. These tests guard that mouse
// selection resolves against those child rows, plans are never pinned above the
// composer, and selection extends across wheel scrolling.
func TestFooterHidesPlanPanelDuringSubchat(t *testing.T) {
func TestFooterDoesNotPinPlanDuringSubchat(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 30
width := m.chatColumnWidth()
withoutSubchat := m.footerView(width)
if !strings.Contains(withoutSubchat, "Step number 1 here") {
t.Fatalf("sanity check failed: pinned plan panel should render outside subchat, got:\n%s", withoutSubchat)
if strings.Contains(withoutSubchat, "Step number 1 here") {
t.Fatalf("plan must render in the transcript rather than footer, got:\n%s", withoutSubchat)
}
m.subchat.active = true
m.subchat.childSessionID = "child-1"
withSubchat := m.footerView(width)
if strings.Contains(withSubchat, "Step number 1 here") {
t.Fatalf("plan panel must NOT show while viewing a subchat child session, got:\n%s", withSubchat)
t.Fatalf("plan must not appear in a child-session footer, got:\n%s", withSubchat)
}
}
+217 -7
View File
@@ -62,22 +62,127 @@ func cachedLexerForPath(path string) chroma.Lexer {
// 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
// (truecolor → 256 → 16 → plain on no-TTY).
type codeSyntaxStyle struct {
foreground string
bold bool
italic bool
}
type codeSyntaxTheme struct {
text codeSyntaxStyle
keyword codeSyntaxStyle
typeName codeSyntaxStyle
function codeSyntaxStyle
name codeSyntaxStyle
string codeSyntaxStyle
number codeSyntaxStyle
comment codeSyntaxStyle
preprocessor codeSyntaxStyle
operator codeSyntaxStyle
punctuation codeSyntaxStyle
}
func codeStyle(foreground string) codeSyntaxStyle { return codeSyntaxStyle{foreground: foreground} }
func boldCodeStyle(foreground string) codeSyntaxStyle {
return codeSyntaxStyle{foreground: foreground, bold: true}
}
func italicCodeStyle(foreground string) codeSyntaxStyle {
return codeSyntaxStyle{foreground: foreground, italic: true}
}
func codeThemeForMode(mode themeMode) *codeSyntaxTheme {
theme, ok := codeThemes[mode]
if !ok {
return nil
}
return &theme
}
func (theme codeSyntaxTheme) styleFor(tt chroma.TokenType) (codeSyntaxStyle, bool) {
var style codeSyntaxStyle
switch {
case tt == chroma.CommentPreproc:
style = theme.preprocessor
case tt.InCategory(chroma.Comment):
style = theme.comment
case tt == chroma.KeywordType:
style = theme.typeName
case tt.InCategory(chroma.Keyword):
style = theme.keyword
case tt == chroma.NameFunction:
style = theme.function
case tt == chroma.NameClass || tt == chroma.NameBuiltin || tt == chroma.NameNamespace || tt == chroma.NameDecorator || tt == chroma.NameAttribute || tt == chroma.NameTag:
style = theme.name
case tt.InSubCategory(chroma.LiteralString):
style = theme.string
case tt.InSubCategory(chroma.LiteralNumber):
style = theme.number
case tt.InCategory(chroma.Operator):
style = theme.operator
case tt.InCategory(chroma.Punctuation):
style = theme.punctuation
default:
style = theme.text
}
return style, style.foreground != ""
}
// tokenStyle maps a lexical token through the active palette's complete syntax
// style when one is available. Palette-local fallbacks keep System, inverted, and
// unmatched themes readable without forcing an unrelated code scheme on them.
func tokenStyle(tt chroma.TokenType) lipgloss.Style {
return tokenStyleForTheme(zeroTheme, tt)
}
func tokenStyleForTheme(theme tuiTheme, tt chroma.TokenType) lipgloss.Style {
if syntax := theme.codeTheme; syntax != nil {
if entry, ok := syntax.styleFor(tt); ok {
return codeSyntaxLipglossStyle(entry)
}
}
return fallbackTokenStyle(theme, tt)
}
func codeSyntaxLipglossStyle(entry codeSyntaxStyle) lipgloss.Style {
style := lipgloss.NewStyle().Foreground(lipgloss.Color(entry.foreground))
if entry.bold {
style = style.Bold(true)
}
if entry.italic {
style = style.Italic(true)
}
return style
}
// inlineCodeStyle gives short code spans the same palette-aware treatment as
// code blocks without turning ordinary assistant prose into a code block.
func inlineCodeStyle() lipgloss.Style {
if syntax := zeroTheme.codeTheme; syntax != nil && syntax.function.foreground != "" {
return codeSyntaxLipglossStyle(syntax.function)
}
return zeroTheme.blue
}
// fallbackTokenStyle is the contrast-audited semantic mapping used by System,
// inverted palettes, and palettes without a corresponding bundled syntax style.
func fallbackTokenStyle(theme tuiTheme, tt chroma.TokenType) lipgloss.Style {
switch {
case tt.InCategory(chroma.Keyword):
return zeroTheme.accent
return theme.accent
case tt.InCategory(chroma.Comment):
return zeroTheme.faint
return theme.faint
case tt.InSubCategory(chroma.LiteralString):
return zeroTheme.green
return theme.green
case tt.InSubCategory(chroma.LiteralNumber):
return zeroTheme.amber
return theme.amber
case tt == chroma.NameFunction || tt == chroma.NameClass || tt == chroma.NameBuiltin || tt == chroma.NameNamespace || tt == chroma.NameDecorator:
return zeroTheme.blue
return theme.blue
case tt.InCategory(chroma.Operator), tt.InCategory(chroma.Punctuation):
return zeroTheme.muted
return theme.muted
default:
return zeroTheme.ink
return theme.ink
}
}
@@ -101,6 +206,111 @@ func highlightCodeForPath(code []string, path string, measure int, bg color.Colo
return highlightCodeWithLexer(cachedLexerForPath(path), code, measure, bg)
}
// highlightShellCommand styles a one-line command for a tool-card heading.
// Command output is deliberately left untouched: it may be structured data,
// prose, or terminal control text rather than shell source.
func highlightShellCommand(command string) (string, bool) {
if command == "" {
return "", false
}
lexer := cachedLexer("bash")
if lexer == nil {
return "", false
}
iterator, err := lexer.Tokenise(nil, command)
if err != nil {
return "", false
}
var builder strings.Builder
expectingCommand := true
for _, token := range iterator.Tokens() {
if token.Type == chroma.Text || token.Type == chroma.TextWhitespace {
builder.WriteString(highlightShellText(token.Value, &expectingCommand))
continue
}
builder.WriteString(tokenStyle(token.Type).Render(token.Value))
if token.Type.InCategory(chroma.Operator) || (token.Type.InCategory(chroma.Punctuation) && strings.ContainsAny(token.Value, ";|")) {
expectingCommand = true
}
}
return builder.String(), true
}
func highlightShellText(text string, expectingCommand *bool) string {
var builder strings.Builder
for start := 0; start < len(text); {
end := start
space := text[start] == ' ' || text[start] == '\t'
for end < len(text) {
isSpace := text[end] == ' ' || text[end] == '\t'
if isSpace != space {
break
}
end++
}
part := text[start:end]
if space {
builder.WriteString(part)
start = end
continue
}
clean := strings.Trim(part, "\"'")
style := tokenStyle(chroma.Text)
switch {
case isShellControlOperator(clean):
style = tokenStyle(chroma.Operator)
*expectingCommand = true
case *expectingCommand:
style = tokenStyle(chroma.NameFunction)
*expectingCommand = isShellCommandWrapper(clean)
case strings.HasPrefix(clean, "-"):
style = tokenStyle(chroma.NameAttribute)
case isShellNumber(clean):
style = tokenStyle(chroma.LiteralNumber)
case looksLikeShellPath(clean):
style = tokenStyle(chroma.LiteralString)
}
builder.WriteString(style.Render(part))
start = end
}
return builder.String()
}
func isShellControlOperator(value string) bool {
switch value {
case "&&", "||", "|", ";":
return true
default:
return false
}
}
func isShellCommandWrapper(value string) bool {
switch value {
case "sudo", "env", "command", "time", "xargs":
return true
default:
return false
}
}
func isShellNumber(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func looksLikeShellPath(value string) bool {
return strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") || strings.HasPrefix(value, "/") || strings.HasPrefix(value, "~/") || strings.Contains(value, "/")
}
// highlightSpan overlays a background on a rune range in one source line while
// retaining the lexer-selected foreground. Diff rendering uses it to keep the
// precise changed word visible inside a syntax-highlighted add/delete row.
+116 -1
View File
@@ -1,8 +1,13 @@
package tui
import (
"fmt"
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2"
"github.com/charmbracelet/colorprofile"
)
func TestStreamingCodeRendersHighlighted(t *testing.T) {
@@ -19,6 +24,26 @@ func TestStreamingCodeRendersHighlighted(t *testing.T) {
}
}
func TestStreamingInlineCodeUsesFinalStyleWithoutReverseVideoFlash(t *testing.T) {
previousProfile := lipgloss.Writer.Profile
lipgloss.Writer.Profile = colorprofile.TrueColor
defer func() { lipgloss.Writer.Profile = previousProfile }()
m := model{
streamingText: []byte("Use `calculator.go` and run `go test ./...`."),
pending: true,
}
out := m.interimBlock(90)
if strings.Contains(out, markdownCodeStart) || strings.Contains(out, markdownCodeEnd) {
t.Fatalf("streaming inline code must not emit reverse-video markers: %q", out)
}
for _, code := range []string{"calculator.go", "go test ./..."} {
if want := inlineCodeStyle().Render(code); !strings.Contains(out, want) {
t.Fatalf("streaming inline code %q should use its final style:\n%s", code, out)
}
}
}
func TestFinalBarePythonCodeRendersHighlighted(t *testing.T) {
row := transcriptRow{
kind: rowAssistant,
@@ -45,12 +70,102 @@ func TestFinalBarePythonCodeRendersHighlighted(t *testing.T) {
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)) {
if !strings.Contains(out, tokenStyle(chroma.Keyword).Render(wantStyled)) {
t.Fatalf("final bare Python code should color keyword %q, got:\n%s", wantStyled, out)
}
}
}
func TestSelectablePaletteUsesItsSyntaxTheme(t *testing.T) {
for _, entry := range themeRegistry {
if entry.Name == string(themeDark) || entry.Name == string(themeLight) {
continue // legacy aliases resolve to System and are not user-selectable
}
t.Run(entry.Name, func(t *testing.T) {
_, theme := themeForMode(themeMode(entry.Name), entry.IsDark)
if theme.codeTheme == nil {
t.Fatalf("%s should select its palette syntax theme", entry.Name)
}
want := theme.codeTheme.keyword.foreground
got := styleForegroundHex(t, tokenStyleForTheme(theme, chroma.Keyword))
if got != want {
t.Fatalf("keyword foreground = %q, want %q", got, want)
}
})
}
}
func TestInvertedPaletteRetainsContrastSafeSyntaxFallback(t *testing.T) {
_, theme := themeForMode("nord", false)
if theme.codeTheme != nil {
t.Fatal("an inverted dark palette must not use a mismatched dark syntax style")
}
got := styleForegroundHex(t, tokenStyleForTheme(theme, chroma.Keyword))
want := styleForegroundHex(t, theme.accent)
if got != want {
t.Fatalf("fallback keyword foreground = %q, want palette accent %q", got, want)
}
}
func TestDiffSyntaxKeepsThemeColorOverDiffSurface(t *testing.T) {
previous := zeroTheme
defer func() { zeroTheme = previous }()
_, zeroTheme = themeForMode("nord", true)
lines, ok := highlightCodeForPath([]string{"func main() {}"}, "main.go", 80, zeroTheme.addLine.GetBackground())
if !ok || len(lines) != 1 {
t.Fatalf("highlightCodeForPath = %#v, %t", lines, ok)
}
want := tokenStyle(chroma.Keyword).Background(zeroTheme.addLine.GetBackground()).Render("func")
if !strings.Contains(lines[0], want) {
t.Fatalf("diff syntax should retain themed foreground over the add surface:\n%s", lines[0])
}
if got := styleForegroundHex(t, tokenStyle(chroma.Keyword)); got != "#81a1c1" {
t.Fatalf("Nord keyword color = %q, want #81a1c1", got)
}
}
func TestInlineCodeAndShellCommandsUseTheActiveSyntaxPalette(t *testing.T) {
previous := zeroTheme
defer func() { zeroTheme = previous }()
_, zeroTheme = themeForMode("nord", true)
inline := styleAssistantMarkdownLine(renderMarkdownInline("Run `gofmt` before committing."), zeroTheme.ink)
if want := inlineCodeStyle().Render("gofmt"); !strings.Contains(inline, want) {
t.Fatalf("inline code should use the active palette:\n%s", inline)
}
command, ok := highlightShellCommand("gofmt -w calculator.go && go run . divide 9 2")
if !ok {
t.Fatal("bash command should have a cached syntax lexer")
}
if plain := ansiPattern.ReplaceAllString(command, ""); plain != "gofmt -w calculator.go && go run . divide 9 2" {
t.Fatalf("command highlight changed visible command to %q", plain)
}
if !strings.Contains(command, "\x1b[") {
t.Fatalf("shell command should be syntax styled, got %q", command)
}
for token, style := range map[string]lipgloss.Style{
"gofmt": tokenStyle(chroma.NameFunction),
"-w": tokenStyle(chroma.NameAttribute),
"9": tokenStyle(chroma.LiteralNumber),
} {
if want := style.Render(token); !strings.Contains(command, want) {
t.Fatalf("shell token %q should use its semantic style:\n%s", token, command)
}
}
}
func styleForegroundHex(t *testing.T, style lipgloss.Style) string {
t.Helper()
foreground := style.GetForeground()
if foreground == nil {
t.Fatal("style has no foreground")
}
r, g, b, _ := foreground.RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
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[") {
+253 -13
View File
@@ -1,20 +1,25 @@
package tui
import (
"fmt"
"image/color"
"strconv"
"charm.land/lipgloss/v2"
)
// tuiTheme is the resolved terminal palette Zero renders with. It is produced by
// buildTheme from a palette, so the same renderers serve both the dark default
// and the light variant; the active theme lives in the package var zeroTheme and
// may be swapped at startup (background detection / ZERO_THEME / --theme) or live
// via /theme. Colors are truecolor hex; lipgloss downsamples on limited displays
// and renders plain text when there is no TTY (tests). Every renderer consumes
// these named styles — no hex literal may appear outside theme_palettes.go (the
// palette tables + theme registry).
// tuiTheme is the resolved terminal palette Zero renders with. The default system
// theme keeps the terminal's foreground and background intact; named palettes only
// style local UI surfaces. The active theme lives in zeroTheme and changes only
// after an explicit /theme selection. Every renderer consumes these named styles —
// no hex literal may appear outside theme_palettes.go (the palette tables + theme
// registry).
type tuiTheme struct {
// codeTheme supplies lexical colors for code views and diff bodies. It is nil
// when the active palette has no matching code palette, in which case the
// renderer uses the existing semantic token colors below.
codeTheme *codeSyntaxTheme
// Base tokens.
ink lipgloss.Style // primary text
muted lipgloss.Style // secondary text, assistant interim prose
@@ -128,7 +133,9 @@ type palette struct {
cardPerm string // permission card border (amber mixed into line)
}
// buildTheme resolves a palette into the styles every renderer uses.
// buildTheme resolves a named palette into the styles every renderer uses. Palette
// backgrounds are intentionally limited to local cards and diff rows; View never
// paints the full terminal canvas.
func buildTheme(p palette) tuiTheme {
col := func(s string) color.Color { return lipgloss.Color(s) }
fg := func(s string) lipgloss.Style { return lipgloss.NewStyle().Foreground(col(s)) }
@@ -204,10 +211,243 @@ func buildTheme(p palette) tuiTheme {
}
}
// zeroTheme is the active palette every renderer reads. It defaults to dark and is
// reassigned by theme selection at startup and by /theme. All references are
// .field accesses evaluated at render time, so reassigning this var repaints every
// subsequent render.
// paletteForTerminal keeps a palette readable without changing the terminal's
// canvas. A light palette on a dark terminal (or the reverse) mirrors its colors,
// preserving the palette's relationships while switching it to the terminal's
// contrast direction. This lets every listed theme remain usable with transparent
// terminals and wallpapers.
func paletteForTerminal(p palette, paletteDark, terminalDark bool) palette {
if paletteDark == terminalDark {
return p
}
return invertedPalette(p)
}
func invertedPalette(p palette) palette {
p.ink = invertPaletteColor(p.ink)
p.muted = invertPaletteColor(p.muted)
p.faint = invertPaletteColor(p.faint)
p.faintest = invertPaletteColor(p.faintest)
p.accent = invertPaletteColor(p.accent)
p.green = invertPaletteColor(p.green)
p.red = invertPaletteColor(p.red)
p.amber = invertPaletteColor(p.amber)
p.blue = invertPaletteColor(p.blue)
p.gitAdd = invertPaletteColor(p.gitAdd)
p.gitDel = invertPaletteColor(p.gitDel)
p.line = invertPaletteColor(p.line)
p.line2 = invertPaletteColor(p.line2)
p.panel = invertPaletteColor(p.panel)
p.promptBg = invertPaletteColor(p.promptBg)
p.permBg = invertPaletteColor(p.permBg)
p.addBg = invertPaletteColor(p.addBg)
p.delBg = invertPaletteColor(p.delBg)
p.addBgWord = invertPaletteColor(p.addBgWord)
p.delBgWord = invertPaletteColor(p.delBgWord)
p.selBg = invertPaletteColor(p.selBg)
p.addInk = invertPaletteColor(p.addInk)
p.delInk = invertPaletteColor(p.delInk)
p.onAccent = invertPaletteColor(p.onAccent)
p.cardRun = invertPaletteColor(p.cardRun)
p.cardErr = invertPaletteColor(p.cardErr)
p.cardPerm = invertPaletteColor(p.cardPerm)
return p
}
func invertPaletteColor(value string) string {
if len(value) != 7 || value[0] != '#' {
return value
}
rgb, err := strconv.ParseUint(value[1:], 16, 32)
if err != nil {
return value
}
r := float64((rgb>>16)&0xff) / 255
g := float64((rgb>>8)&0xff) / 255
b := float64(rgb&0xff) / 255
h, s, l := rgbToHSL(r, g, b)
r, g, b = hslToRGB(h, s, 1-l)
return fmt.Sprintf("#%02x%02x%02x", colorChannel(r), colorChannel(g), colorChannel(b))
}
func rgbToHSL(r, g, b float64) (h, s, l float64) {
max, min := r, r
for _, component := range []float64{g, b} {
if component > max {
max = component
}
if component < min {
min = component
}
}
l = (max + min) / 2
delta := max - min
if delta == 0 {
return 0, 0, l
}
if l > 0.5 {
s = delta / (2 - max - min)
} else {
s = delta / (max + min)
}
switch max {
case r:
h = (g - b) / delta
if g < b {
h += 6
}
case g:
h = (b-r)/delta + 2
default:
h = (r-g)/delta + 4
}
return h / 6, s, l
}
func hslToRGB(h, s, l float64) (r, g, b float64) {
if s == 0 {
return l, l, l
}
q := l * (1 + s)
if l >= 0.5 {
q = l + s - l*s
}
p := 2*l - q
return hueToRGB(p, q, h+1.0/3), hueToRGB(p, q, h), hueToRGB(p, q, h-1.0/3)
}
func hueToRGB(p, q, t float64) float64 {
if t < 0 {
t++
}
if t > 1 {
t--
}
switch {
case t < 1.0/6:
return p + (q-p)*6*t
case t < 0.5:
return q
case t < 2.0/3:
return p + (q-p)*(2.0/3-t)*6
default:
return p
}
}
func colorChannel(value float64) int {
if value <= 0 {
return 0
}
if value >= 1 {
return 255
}
return int(value*255 + 0.5)
}
// buildSystemTheme is the dark-terminal default for package-level render helpers
// and tests. The running TUI resolves the matching contrast direction through
// buildSystemThemeForTerminal.
func buildSystemTheme() tuiTheme {
return buildSystemThemeForTerminal(true)
}
// buildSystemThemeForTerminal preserves the terminal canvas and its foreground
// color. It uses ANSI role colors for semantic cues and confines fixed local
// surfaces to selected and diff rows, so those states remain visible even in
// terminals where reverse-video is disabled or visually indistinguishable.
func buildSystemThemeForTerminal(terminalDark bool) tuiTheme {
base := lipgloss.NewStyle()
accentColor := lipgloss.Color("6")
greenColor := lipgloss.Color("2")
redColor := lipgloss.Color("1")
amberColor := lipgloss.Color("3")
blueColor := lipgloss.Color("4")
// Local surfaces use quiet truecolor tints. They do not alter the terminal
// canvas, but remain legible beside syntax colors on terminals that support
// them. The dark diff values are intentionally close to the canvas.
surfaces := darkSystemSurfaces
if !terminalDark {
surfaces = lightSystemSurfaces
}
selectionBg := lipgloss.Color(surfaces.selection)
addBg := lipgloss.Color(surfaces.add)
delBg := lipgloss.Color(surfaces.del)
addBgWord := lipgloss.Color(surfaces.addWord)
delBgWord := lipgloss.Color(surfaces.delWord)
noColor := lipgloss.NoColor{}
accent := lipgloss.NewStyle().Foreground(accentColor).Bold(true)
green := lipgloss.NewStyle().Foreground(greenColor)
red := lipgloss.NewStyle().Foreground(redColor)
amber := lipgloss.NewStyle().Foreground(amberColor)
blue := lipgloss.NewStyle().Foreground(blueColor)
return tuiTheme{
ink: base,
muted: base.Faint(true),
faint: base.Faint(true),
faintest: base.Faint(true),
accent: accent,
green: green,
red: red,
amber: amber,
blue: blue,
gitAdd: green,
gitDel: red,
line: base.Faint(true),
lineStrong: base.Faint(true),
selection: base.Background(selectionBg),
hover: base.Underline(true),
badge: base.Foreground(accentColor).Bold(true),
userPrompt: base.Foreground(accentColor).Bold(true),
sayText: base.Faint(true),
toolName: base.Bold(true),
toolTarget: base,
toolArg: base.Faint(true),
cardRun: accent,
cardErr: red,
bashPrompt: accent,
grepLoc: blue,
diffAdd: green,
diffDel: red,
diffMeta: base.Faint(true),
addLineWord: green.Bold(true).Underline(true).Background(addBgWord),
delLineWord: red.Bold(true).Underline(true).Background(delBgWord),
addLine: base.Background(addBg),
delLine: base.Background(delBg),
addLineNum: base.Faint(true).Background(addBg),
delLineNum: base.Faint(true).Background(delBg),
addSign: green.Background(addBg),
delSign: red.Background(delBg),
delText: red,
permBadge: amber.Bold(true),
permBg: base,
permBorder: amber,
panel: base,
userPromptPanel: base,
modeAuto: green.Bold(true),
modeAsk: amber,
modeUnsafe: red.Bold(true),
modePlan: blue.Bold(true),
accentColor: accentColor,
inkColor: noColor,
bgPanel: noColor,
bgPrompt: noColor,
bgSel: selectionBg,
bgPerm: noColor,
}
}
// zeroTheme is the active palette every renderer reads. Run applies the terminal-
// native system default before it creates the interactive program; the dark palette
// here keeps package-level render helpers deterministic before a model has started
// (including in unit tests that construct more than one model).
var zeroTheme = buildTheme(darkPalette)
// onPanel returns a copy of style that paints on the panel surface. lipgloss
+188 -16
View File
@@ -2,8 +2,8 @@ package tui
import "strings"
// theme_palettes.go is the sole home of raw color hex in the TUI: every palette
// literal and the ordered theme registry live here, so theme.go stays hex-free
// theme_palettes.go is the sole home of raw color hex in the TUI: every UI and
// code palette literal and the ordered theme registry live here, so theme.go stays hex-free
// (just the palette struct, buildTheme, and the resolved tuiTheme). Adding a theme
// is a new palette{...} literal plus one themeRegistry entry — nothing else.
//
@@ -13,6 +13,30 @@ import "strings"
// their diff bands and word spans; the muted>faint>faintest ramp monotonic toward
// the surface; and selBg visibly separated from panel while its label stays legible.
type systemSurfacePalette struct {
selection string
add string
del string
addWord string
delWord string
}
var darkSystemSurfaces = systemSurfacePalette{
selection: "#2b2f2d",
add: "#212922",
del: "#3c170f",
addWord: "#212922",
delWord: "#3c170f",
}
var lightSystemSurfaces = systemSurfacePalette{
selection: "#e7e9e7",
add: "#dafbe1",
del: "#ffebe9",
addWord: "#aceebb",
delWord: "#ffcecb",
}
// darkPalette is the original Lime palette: a near-black chat surface with one
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
// painted — so no token references it.
@@ -465,9 +489,168 @@ var dunePalette = palette{
cardPerm: "#c2a04a",
}
// codeThemes provide syntax-token colors for named palettes. Zero continues to
// use its cached Chroma lexers; this compact table avoids initializing an
// unrelated global syntax-theme registry just to select a palette.
var codeThemes = map[themeMode]codeSyntaxTheme{
"dracula": {
text: codeStyle("#f8f8f2"),
keyword: codeStyle("#ff79c6"),
typeName: codeStyle("#8be9fd"),
function: codeStyle("#50fa7b"),
name: codeStyle("#50fa7b"),
string: codeStyle("#f1fa8c"),
number: codeStyle("#bd93f9"),
comment: codeStyle("#6272a4"),
preprocessor: codeStyle("#ff79c6"),
operator: codeStyle("#ff79c6"),
punctuation: codeStyle("#f8f8f2"),
},
"nord": {
text: codeStyle("#d8dee9"),
keyword: boldCodeStyle("#81a1c1"),
typeName: codeStyle("#81a1c1"),
function: codeStyle("#88c0d0"),
name: codeStyle("#8fbcbb"),
string: codeStyle("#a3be8c"),
number: codeStyle("#b48ead"),
comment: italicCodeStyle("#616e87"),
preprocessor: codeStyle("#5e81ac"),
operator: codeStyle("#81a1c1"),
punctuation: codeStyle("#eceff4"),
},
"gruvbox": {
text: codeStyle("#ebdbb2"),
keyword: codeStyle("#fe8019"),
typeName: codeStyle("#fabd2f"),
function: codeStyle("#fabd2f"),
name: boldCodeStyle("#b8bb26"),
string: codeStyle("#b8bb26"),
number: codeStyle("#d3869b"),
comment: italicCodeStyle("#928374"),
preprocessor: codeStyle("#8ec07c"),
operator: codeStyle("#fe8019"),
punctuation: codeStyle("#ebdbb2"),
},
"tokyo-night": {
text: codeStyle("#c0caf5"),
keyword: codeStyle("#bb9af7"),
typeName: codeStyle("#41a6b5"),
function: codeStyle("#7aa2f7"),
name: codeStyle("#9ece6a"),
string: codeStyle("#9ece6a"),
number: codeStyle("#e0af68"),
comment: italicCodeStyle("#414868"),
preprocessor: italicCodeStyle("#414868"),
operator: boldCodeStyle("#9ece6a"),
punctuation: codeStyle("#c0caf5"),
},
"catppuccin": {
text: codeStyle("#cdd6f4"),
keyword: codeStyle("#cba6f7"),
typeName: codeStyle("#f38ba8"),
function: codeStyle("#89b4fa"),
name: codeStyle("#f9e2af"),
string: codeStyle("#a6e3a1"),
number: codeStyle("#fab387"),
comment: italicCodeStyle("#6c7086"),
preprocessor: italicCodeStyle("#6c7086"),
operator: boldCodeStyle("#89dceb"),
punctuation: codeStyle("#cdd6f4"),
},
"one-dark": {
text: codeStyle("#abb2bf"),
keyword: codeStyle("#c678dd"),
typeName: codeStyle("#e5c07b"),
function: boldCodeStyle("#61afef"),
name: codeStyle("#e5c07b"),
string: codeStyle("#98c379"),
number: codeStyle("#d19a66"),
comment: codeStyle("#7f848e"),
operator: codeStyle("#56b6c2"),
punctuation: codeStyle("#abb2bf"),
},
"solarized-dark": {
text: codeStyle("#839496"),
keyword: codeStyle("#719e07"),
typeName: codeStyle("#dc322f"),
function: codeStyle("#268bd2"),
name: codeStyle("#b58900"),
string: codeStyle("#2aa198"),
number: codeStyle("#2aa198"),
comment: codeStyle("#586e75"),
preprocessor: codeStyle("#719e07"),
operator: codeStyle("#719e07"),
punctuation: codeStyle("#839496"),
},
"solarized-light": {
text: codeStyle("#586e75"),
keyword: codeStyle("#859900"),
typeName: boldCodeStyle("#586e75"),
function: codeStyle("#586e75"),
name: codeStyle("#cb4b16"),
string: codeStyle("#586e75"),
number: boldCodeStyle("#586e75"),
comment: italicCodeStyle("#93a1a1"),
operator: codeStyle("#586e75"),
punctuation: codeStyle("#586e75"),
},
"rose-pine": {
text: codeStyle("#e0def4"),
keyword: codeStyle("#31748f"),
typeName: codeStyle("#9ccfd8"),
function: codeStyle("#ebbcba"),
name: codeStyle("#ebbcba"),
string: codeStyle("#f6c177"),
number: codeStyle("#f6c177"),
comment: codeStyle("#6e6a86"),
operator: codeStyle("#908caa"),
punctuation: codeStyle("#908caa"),
},
"everforest": {
text: codeStyle("#d3c6aa"),
keyword: codeStyle("#a7c080"),
typeName: codeStyle("#dbbc7f"),
function: codeStyle("#7fbbb3"),
name: codeStyle("#83c092"),
string: codeStyle("#a7c080"),
number: codeStyle("#e67e80"),
comment: italicCodeStyle("#a4aea3"),
preprocessor: codeStyle("#dbbc7f"),
operator: codeStyle("#7fbbb3"),
punctuation: codeStyle("#d3c6aa"),
},
"neon": {
text: codeStyle("#c9ffd2"),
keyword: boldCodeStyle("#00e5c8"),
typeName: codeStyle("#22e0ff"),
function: codeStyle("#39ff6a"),
name: codeStyle("#39ff6a"),
string: codeStyle("#f4ff3a"),
number: codeStyle("#ff4d6d"),
comment: italicCodeStyle("#74c468"),
preprocessor: codeStyle("#00e5c8"),
operator: codeStyle("#22e0ff"),
punctuation: codeStyle("#c9ffd2"),
},
"dune": {
text: codeStyle("#2b241a"),
keyword: boldCodeStyle("#724028"),
typeName: codeStyle("#2f5680"),
function: codeStyle("#38572a"),
name: codeStyle("#2f5680"),
string: codeStyle("#38572a"),
number: codeStyle("#872d24"),
comment: italicCodeStyle("#655648"),
preprocessor: codeStyle("#6d4600"),
operator: codeStyle("#724028"),
punctuation: codeStyle("#2b241a"),
},
}
// themeEntry is one registered theme: Name is the /theme value + ZERO_THEME/--theme
// token (lowercase, kebab), Label is the picker display text, and IsDark groups the
// picker (Dark/Light sections) and drives which built-in `auto` resolves to.
// picker into Dark/Light sections.
type themeEntry struct {
Name string
Label string
@@ -478,7 +661,7 @@ type themeEntry struct {
// themeRegistry is the ordered source of truth for every selectable theme. Order is
// the picker order: all Dark themes first, then all Light, with the brand
// dark/light built-ins leading their groups. themeModes (theme_select.go) prepends
// `auto` to this. Append here to add a theme — nothing else needs editing.
// `system` to this. Append here to add a theme — nothing else needs editing.
var themeRegistry = []themeEntry{
{Name: "dark", Label: "dark", Palette: darkPalette, IsDark: true},
{Name: "dracula", Label: "Dracula", Palette: draculaPalette, IsDark: true},
@@ -496,9 +679,7 @@ var themeRegistry = []themeEntry{
{Name: "dune", Label: "Dune", Palette: dunePalette, IsDark: false},
}
// themeByName indexes the registry by lowercased name for O(1) lookup. Built as a
// var initializer (not init()) so it is ready before themeModes' package-var
// initializer calls themeNames().
// themeByName indexes the registry by lowercased name for O(1) lookup.
var themeByName = func() map[string]themeEntry {
byName := make(map[string]themeEntry, len(themeRegistry))
for _, entry := range themeRegistry {
@@ -512,12 +693,3 @@ func lookupTheme(name string) (themeEntry, bool) {
entry, ok := themeByName[strings.ToLower(strings.TrimSpace(name))]
return entry, ok
}
// themeNames returns every registered theme name in registry (picker) order.
func themeNames() []string {
names := make([]string, len(themeRegistry))
for index, entry := range themeRegistry {
names[index] = entry.Name
}
return names
}
+94 -181
View File
@@ -5,10 +5,11 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// A committed theme is written to user config and reloaded at startup (via
@@ -17,8 +18,6 @@ func TestThemeChoicePersistsAcrossRestart(t *testing.T) {
defer applyTheme(themeDark, true)
cfgPath := filepath.Join(t.TempDir(), "config.json")
// First session: pick a color theme via the /theme command (same commit path the
// picker uses).
m := newModel(context.Background(), Options{UserConfigPath: cfgPath})
m, _ = m.handleThemeCommand("dracula")
if m.themeMode != themeMode("dracula") {
@@ -40,14 +39,12 @@ func TestThemeChoicePersistsAcrossRestart(t *testing.T) {
t.Fatalf("preferences.theme = %q, want dracula", cfg.Preferences.Theme)
}
// Second session: the persisted theme seeds startup (no flag / env).
restarted := newModel(context.Background(), Options{UserConfigPath: cfgPath, SavedTheme: "dracula"})
if restarted.themeMode != themeMode("dracula") {
t.Fatalf("restarted themeMode = %q, want dracula (from saved config)", restarted.themeMode)
}
}
// resolveThemeMode precedence: flag > ZERO_THEME > persisted config > auto.
func TestResolveThemeModeConfigFallback(t *testing.T) {
if got := resolveThemeMode("", "", "nord"); got != themeMode("nord") {
t.Errorf("saved-only = %q, want nord", got)
@@ -58,13 +55,14 @@ func TestResolveThemeModeConfigFallback(t *testing.T) {
if got := resolveThemeMode("", "gruvbox", "nord"); got != themeMode("gruvbox") {
t.Errorf("env should beat saved: got %q, want gruvbox", got)
}
if got := resolveThemeMode("", "", "bogus-theme"); got != themeAuto {
t.Errorf("unknown saved theme should fall back to auto, got %q", got)
if got := resolveThemeMode("", "", "bogus-theme"); got != themeSystem {
t.Errorf("unknown saved theme should fall back to system, got %q", got)
}
if got := resolveThemeMode("auto"); got != themeSystem {
t.Errorf("legacy auto = %q, want system", got)
}
}
// themePickerRowIndex finds the picker row whose Value is name (index-independent,
// so tests survive registry reordering / additions).
func themePickerRowIndex(t *testing.T, p *commandPicker, name string) int {
t.Helper()
for i, item := range p.items {
@@ -76,27 +74,13 @@ func themePickerRowIndex(t *testing.T, p *commandPicker, name string) int {
return -1
}
// assertPreviewMatchesSelection checks the live palette equals the palette of the
// currently highlighted theme row (the auto row has no palette of its own).
func assertPreviewMatchesSelection(t *testing.T, m model) {
t.Helper()
sel := m.picker.items[m.picker.selected]
entry, ok := lookupTheme(sel.Value)
if !ok {
return
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, entry.Palette.ink) {
t.Errorf("preview of %q did not apply its palette (ink mismatch)", sel.Value)
}
}
// Bare `/theme` opens the popup picker (like /model and /effort) with one row per
// theme mode, preselecting the active preference. An explicit `/theme <mode>` must
// keep taking the text path so scripts and the existing state view still work.
// Bare `/theme` opens the popup picker with one row per presented theme mode,
// preselecting the committed choice. An explicit `/theme <mode>` keeps taking the
// text path so scripts remain predictable.
func TestThemePickerOpensOnBareTheme(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeLight
m.themeMode = themeMode("dracula")
m.input.SetValue("/theme")
updated, cmd := m.Update(testKey(tea.KeyEnter))
@@ -110,80 +94,81 @@ func TestThemePickerOpensOnBareTheme(t *testing.T) {
if len(m.picker.items) != len(themeModes) {
t.Fatalf("picker has %d items, want %d", len(m.picker.items), len(themeModes))
}
for i, want := range themeModes {
if m.picker.items[i].Value != want {
t.Errorf("item %d value = %q, want %q", i, m.picker.items[i].Value, want)
}
if got := m.picker.items[0].Value; got != string(themeSystem) {
t.Errorf("first theme = %q, want system", got)
}
if got := m.picker.items[m.picker.selected].Value; got != string(themeLight) {
t.Errorf("preselected value = %q, want the active mode %q", got, themeLight)
if got := m.picker.items[m.picker.selected].Value; got != "dracula" {
t.Errorf("preselected value = %q, want the active mode dracula", got)
}
for _, retired := range []string{string(themeDark), string(themeLight)} {
for _, item := range m.picker.items {
if item.Value == retired {
t.Errorf("retired %q must not appear in the theme picker", retired)
}
}
}
}
// An explicit mode argument runs the text handler directly and never opens the
// popup — guards the dispatch branch and keeps /theme <mode> scriptable.
func TestThemeArgSkipsPicker(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.input.SetValue("/theme dark")
m.input.SetValue("/theme dracula")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatalf("explicit /theme dark must not open a picker, got %#v", m.picker)
t.Fatalf("explicit /theme dracula must not open a picker, got %#v", m.picker)
}
if m.themeMode != themeDark {
t.Fatalf("after /theme dark, mode = %q", m.themeMode)
if m.themeMode != themeMode("dracula") {
t.Fatalf("after /theme dracula, mode = %q", m.themeMode)
}
}
// Moving the cursor live-previews each palette: the global zeroTheme swaps as the
// selection changes, so the whole UI (and the overlay) repaints in the hovered
// theme without committing the preference.
func TestThemePickerPreviewsOnMove(t *testing.T) {
// Moving through the picker changes only the small candidate preview. The active
// palette and committed preference stay exactly as they were until Enter.
func TestThemePickerPreviewIsContained(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.themeMode = themeMode("dracula")
applyTheme(m.themeMode, true)
before, _, _, _ := zeroTheme.inkColor.RGBA()
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Each Down moves to the next theme and live-applies its palette; Up follows back.
for i := 0; i < 3; i++ {
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
}
updated, _ = m.Update(testKey(tea.KeyUp))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
// Preview must not have written the committed preference.
if m.themeMode != themeDark {
after, _, _, _ := zeroTheme.inkColor.RGBA()
if after != before {
t.Fatal("moving through the theme picker changed the active palette")
}
if m.themeMode != themeMode("dracula") {
t.Errorf("preview mutated the committed mode to %q", m.themeMode)
}
view := m.pickerOverlay(100)
for _, want := range []string{"Preview", "no bugs, probably"} {
if !strings.Contains(view, want) {
t.Errorf("contained preview missing %q:\n%s", want, view)
}
}
}
// Enter on a fixed mode commits it: the palette stays applied, the preference is
// recorded, the popup closes, and the switch is noted in the transcript.
func TestThemePickerCommitAppliesAndRecords(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.themeMode = themeMode("dracula")
applyTheme(m.themeMode, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Choose a color theme by locating its row (index-independent), then confirm.
m.picker.selected = themePickerRowIndex(t, m.picker, "dracula")
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if cmd != nil {
t.Fatalf("committing a fixed theme should not emit a cmd, got %T", cmd)
if cmd == nil {
t.Fatal("committing a fixed theme should schedule transient-notice expiry")
}
if m.picker != nil {
t.Fatal("committing should close the picker")
@@ -191,150 +176,78 @@ func TestThemePickerCommitAppliesAndRecords(t *testing.T) {
if m.themeMode != themeMode("dracula") {
t.Fatalf("committed mode = %q, want dracula", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, draculaPalette.ink) {
t.Error("commit did not leave the dracula palette applied")
if got, want := colorHex(t, zeroTheme.inkColor), draculaPalette.ink; got != want {
t.Errorf("committed dracula ink = %s, want %s", got, want)
}
if !transcriptContains(m.transcript, "active theme") {
t.Errorf("commit should record the switch in the transcript:\n%s", transcriptText(m.transcript))
if m.transientNotice.text != "Theme: Dracula" {
t.Errorf("commit notice = %q, want Theme: Dracula", m.transientNotice.text)
}
if transcriptContains(m.transcript, "active theme") {
t.Errorf("commit should not add a durable transcript result:\n%s", transcriptText(m.transcript))
}
}
// Committing `auto` re-probes the terminal background (like the text dispatch) so
// the palette re-detects light/dark instead of reusing the preview's reading.
func TestThemePickerAutoCommitReprobesBackground(t *testing.T) {
func TestThemePickerSystemCommitPreservesTerminalCanvas(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.themeMode = themeMode("dracula")
applyTheme(m.themeMode, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
m.picker.selected = 0 // "auto"
updated, cmd := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.themeMode != themeAuto {
t.Fatalf("committed mode = %q, want auto", m.themeMode)
}
if cmd == nil {
t.Fatal("committing auto must return a background re-probe cmd")
}
if reflect.ValueOf(cmd).Pointer() != reflect.ValueOf(tea.RequestBackgroundColor).Pointer() {
t.Error("committing auto must return tea.RequestBackgroundColor")
}
}
// Typing to filter the list re-previews the newly-highlighted mode, so the applied
// palette never diverges from the row the popup points at. Backspacing back to an
// empty query re-previews the (restored) selection too.
func TestThemePickerFilterRePreviews(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview light by moving, then filter to "light" — the applied palette must
// track the filtered highlight (still light here), not lag behind.
updated, _ = m.Update(testKey(tea.KeyDown)) // dark -> light preview
m = updated.(model)
updated, _ = m.Update(testKeyText("l"))
m = updated.(model)
updated, _ = m.Update(testKeyText("i"))
m = updated.(model)
if got := m.picker.items[m.picker.selected].Value; got != string(themeLight) {
t.Fatalf("filter 'li' should highlight light, got %q", got)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, lightPalette.ink) {
t.Error("filtering to light did not keep the light palette previewed")
}
// Re-filter to "dark": highlight and preview must both switch to dark.
updated, _ = m.Update(testKey(tea.KeyBackspace))
m = updated.(model)
updated, _ = m.Update(testKey(tea.KeyBackspace))
m = updated.(model)
updated, _ = m.Update(testKeyText("d"))
m = updated.(model)
if got := m.picker.items[m.picker.selected].Value; got != string(themeDark) {
t.Fatalf("filter 'd' should highlight dark, got %q", got)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("filtering to dark did not re-preview the dark palette")
}
}
// A filter that matches nothing must not strand the last preview: the palette
// falls back to the committed theme, and committing (Enter on the empty list) is a
// clean no-op that leaves the committed mode and palette in agreement.
func TestThemePickerEmptyFilterRestoresCommitted(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview another theme by moving, then type a char that matches no theme name
// ('q' appears in none) -> the list empties.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
updated, _ = m.Update(testKeyText("q"))
m = updated.(model)
if len(m.picker.items) != 0 {
t.Fatalf("filter 'q' should match no theme, got %d items", len(m.picker.items))
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("an empty filter should restore the committed dark palette, not keep the preview")
}
// Enter on the empty list closes the picker without changing the committed
// preference, and leaves the palette matching it.
m.picker.selected = themePickerRowIndex(t, m.picker, string(themeSystem))
updated, _ = m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker != nil {
t.Fatal("Enter on an empty filter should close the picker")
if m.themeMode != themeSystem {
t.Fatalf("committed mode = %q, want system", m.themeMode)
}
if m.themeMode != themeDark {
t.Fatalf("empty-list commit must not change the mode, got %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("after an empty-list commit the palette must match the committed dark mode")
if _, ok := zeroTheme.bgPanel.(lipgloss.NoColor); !ok {
t.Fatalf("system theme background = %T, want lipgloss.NoColor", zeroTheme.bgPanel)
}
}
// Esc dismisses the picker without choosing and restores the committed palette,
// undoing whatever the live preview applied.
func TestThemePickerEscRestoresCommittedTheme(t *testing.T) {
// Themes are local styling choices. Even a named palette must not use Bubble
// Tea's terminal-wide colour controls, which would replace the user's terminal
// background, transparency, or wallpaper.
func TestThemeViewLeavesTerminalCanvasUntouched(t *testing.T) {
defer applyTheme(themeDark, true)
for _, mode := range []themeMode{themeSystem, themeMode("dracula"), themeMode("dune")} {
t.Run(string(mode), func(t *testing.T) {
m := newModel(context.Background(), Options{SavedTheme: string(mode)})
m.altScreen = true
view := m.View()
if view.BackgroundColor != nil {
t.Fatalf("%s theme set terminal background to %T", mode, view.BackgroundColor)
}
if view.ForegroundColor != nil {
t.Fatalf("%s theme set terminal foreground to %T", mode, view.ForegroundColor)
}
})
}
}
func TestThemePickerFilterAndCancelKeepActiveTheme(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{})
m.themeMode = themeDark
m.hasDarkBg = true
applyTheme(themeDark, true)
m.themeMode = themeMode("dracula")
applyTheme(m.themeMode, true)
before, _, _, _ := zeroTheme.inkColor.RGBA()
m.input.SetValue("/theme")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
// Preview another theme by moving, then bail out.
updated, _ = m.Update(testKey(tea.KeyDown))
updated, _ = m.Update(testKeyText("d"))
m = updated.(model)
assertPreviewMatchesSelection(t, m)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r == mustR(t, darkPalette.ink) {
t.Fatal("expected a non-dark preview to be applied before Esc")
if len(m.picker.items) == 0 || m.picker.items[m.picker.selected].Value != "dracula" {
t.Fatalf("filter should select dracula, got %#v", m.picker.items)
}
updated, _ = m.Update(testKey(tea.KeyEsc))
m = updated.(model)
if m.picker != nil {
t.Fatal("Esc should close the picker")
if m.picker != nil || m.themeMode != themeMode("dracula") {
t.Fatalf("Esc changed picker or committed mode: picker=%#v mode=%q", m.picker, m.themeMode)
}
if m.themeMode != themeDark {
t.Fatalf("Esc must not change the committed mode, got %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, darkPalette.ink) {
t.Error("Esc did not restore the committed dark palette")
after, _, _, _ := zeroTheme.inkColor.RGBA()
if after != before {
t.Fatal("filtering or cancelling changed the active palette")
}
}
+11 -21
View File
@@ -2,35 +2,25 @@ package tui
import (
"context"
"reflect"
"testing"
tea "charm.land/bubbletea/v2"
)
// TestThemeAutoReProbesBackground guards the M17 fix at the command-dispatch level
// (not just the handleThemeCommand helper): selecting `/theme auto` must return
// tea.RequestBackgroundColor so the terminal background is re-detected live, while
// a fixed theme must NOT re-probe. A regression that reverts the dispatch to
// `return m, nil` would otherwise pass the whole suite.
func TestThemeAutoReProbesBackground(t *testing.T) {
// applyTheme mutates global palette state; restore it afterward.
// System no longer guesses a light or dark canvas from the terminal. The legacy
// auto argument is accepted for old configs and scripts, but resolves to the
// terminal-native system preference without issuing a background-color query.
func TestThemeAutoAliasUsesSystemWithoutBackgroundProbe(t *testing.T) {
defer applyTheme(themeDark, true)
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.input.SetValue("/theme auto")
_, cmd := m.handleSubmit()
updated, cmd := m.handleSubmit()
next := updated.(model)
if cmd == nil {
t.Fatal("/theme auto must return a non-nil cmd (background re-probe)")
t.Fatal("/theme auto should schedule a transient confirmation expiry")
}
want := reflect.ValueOf(tea.RequestBackgroundColor).Pointer()
if reflect.ValueOf(cmd).Pointer() != want {
t.Error("/theme auto cmd must be tea.RequestBackgroundColor")
if next.themeMode != themeSystem {
t.Fatalf("/theme auto mode = %q, want system", next.themeMode)
}
m2 := newModel(context.Background(), Options{ModelName: "gpt-4"})
m2.input.SetValue("/theme dark")
if _, cmd2 := m2.handleSubmit(); cmd2 != nil {
t.Error("/theme dark must not return a background-color request")
if next.transientNotice.text != "Theme: System" {
t.Fatalf("/theme auto notice = %q", next.transientNotice.text)
}
}
+79 -85
View File
@@ -10,75 +10,84 @@ import (
type themeMode string
const (
themeAuto themeMode = "auto" // detect terminal background (default)
themeDark themeMode = "dark"
themeLight themeMode = "light"
themeSystem themeMode = "system" // preserve the terminal canvas (default)
themeAuto themeMode = "auto" // legacy alias for system
themeDark themeMode = "dark" // migrated old saved preference
themeLight themeMode = "light" // migrated old saved preference
)
// themeModes lists the values /theme accepts, in picker order: `auto` first, then
// every registered theme (theme_palettes.go). It is the single ordered source
// feeding both the picker and the /theme state list — adding a registry entry
// extends it automatically.
var themeModes = append([]string{string(themeAuto)}, themeNames()...)
// themeModes lists the values /theme presents, in picker order: `system` first,
// then named palettes. Former dark/light saved preferences migrate to System and
// no longer appear as choices.
var themeModes = append([]string{string(themeSystem)}, selectableThemeNames()...)
func selectableThemeNames() []string {
names := make([]string, 0, len(themeRegistry))
for _, entry := range themeRegistry {
if entry.Name == string(themeDark) || entry.Name == string(themeLight) {
continue
}
names = append(names, entry.Name)
}
return names
}
// resolveThemeMode picks the first accepted preference from candidates in
// precedence order — the caller passes them highest-first: the --theme flag, then
// ZERO_THEME, then the persisted config theme. A value is accepted if it is `auto`
// or names a registered theme; unrecognized/blank values are skipped, and an empty
// list (or all-unrecognized) falls back to auto.
// ZERO_THEME, then the persisted config theme. A value is accepted if it is
// `system`, the legacy `auto` alias, or names a registered theme. Unrecognized/
// blank values are skipped, and an empty list (or all-unrecognized) falls back to
// system.
func resolveThemeMode(candidates ...string) themeMode {
for _, v := range candidates {
s := strings.ToLower(strings.TrimSpace(v))
if s == "" {
continue
}
if s == string(themeAuto) {
return themeAuto
if s == string(themeSystem) || s == string(themeAuto) || s == string(themeDark) || s == string(themeLight) {
return themeSystem
}
if _, ok := lookupTheme(s); ok {
return themeMode(s)
}
}
return themeAuto
return themeSystem
}
// validThemeMode reports whether s names a theme mode (for /theme validation):
// `auto` or any registered theme.
// `system`, legacy `auto`, or any visible named palette.
func validThemeMode(s string) bool {
s = strings.ToLower(strings.TrimSpace(s))
if s == string(themeAuto) {
if s == string(themeSystem) || s == string(themeAuto) {
return true
}
if s == string(themeDark) || s == string(themeLight) {
return false
}
_, ok := lookupTheme(s)
return ok
}
// ValidThemeArg reports whether s is an acceptable --theme / ZERO_THEME value
// (`auto` or a registered theme name). Exported so the CLI flag validator shares
// this one source of truth instead of hardcoding the theme list.
func ValidThemeArg(s string) bool { return validThemeMode(s) }
// ValidThemeArg reports whether s is an acceptable --theme / ZERO_THEME value.
// It accepts retired dark/light preferences so existing scripts and environment
// variables migrate to System, while validThemeMode keeps them out of the picker.
func ValidThemeArg(s string) bool {
s = strings.ToLower(strings.TrimSpace(s))
if s == string(themeDark) || s == string(themeLight) {
return true
}
return validThemeMode(s)
}
// applyTheme swaps the active palette (zeroTheme) and the globals derived from it
// — the streaming-fade ramp and the static render cache — so a switch repaints
// every subsequent render. For themeAuto it resolves to dark/light from
// hasDarkBackground; explicit dark/light ignore it. Returns the concrete mode
// applied (never auto). Must run on the Bubble Tea update goroutine (or before the
// program starts), like every other zeroTheme access.
func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
resolved := mode
if mode == themeAuto {
resolved = themeDark
if !hasDarkBackground {
resolved = themeLight
}
}
// Resolve the (now concrete) mode to its registered palette; an unknown name
// falls back to the dark built-in so a bad value can never leave zeroTheme unset.
entry, ok := lookupTheme(string(resolved))
if !ok {
entry, _ = lookupTheme(string(themeDark))
}
zeroTheme = buildTheme(entry.Palette)
// — the streaming-fade ramp and the static render cache — so a committed switch
// repaints every subsequent render. `system` (and legacy `auto`) preserve the
// terminal canvas. Named palettes adapt their contrast direction to the terminal
// background without painting it. Must run on the Bubble Tea update goroutine (or
// before the program starts), like every other zeroTheme access.
func applyTheme(mode themeMode, terminalDark bool) themeMode {
resolved, theme := themeForMode(mode, terminalDark)
zeroTheme = theme
rebuildStreamingFadePalette()
if defaultRenderCache != nil {
defaultRenderCache.clear() // old-palette entries must not be reused
@@ -86,38 +95,25 @@ func applyTheme(mode themeMode, hasDarkBackground bool) themeMode {
return resolved
}
// previewSelectedTheme makes the live palette match the theme picker's current
// state: the highlighted mode when a row is selectable, or the committed
// m.themeMode when the filter matches nothing. Called on every change to the
// picker's selection or filter (arrow/wheel moves, mouse, and query typing) so the
// whole UI — and the overlay itself — always renders the mode the popup points at,
// and never strands on a stale preview. It only swaps the global zeroTheme via
// applyTheme; m.themeMode keeps the committed preference so Esc can restore it. A
// no-op unless a theme picker is open. Runs on the Update goroutine, like every
// zeroTheme access.
func (m model) previewSelectedTheme() {
if m.picker == nil || m.picker.kind != pickerTheme {
return
// themeForMode resolves a candidate without mutating zeroTheme. The /theme picker
// uses it to render a contained preview while the active UI remains untouched.
func themeForMode(mode themeMode, terminalDark bool) (themeMode, tuiTheme) {
if mode == themeSystem || mode == themeAuto || mode == "" {
return themeSystem, buildSystemThemeForTerminal(terminalDark)
}
if item, ok := m.picker.current(); ok {
applyTheme(themeMode(item.Value), m.hasDarkBg)
return
if entry, ok := lookupTheme(string(mode)); ok {
theme := buildTheme(paletteForTerminal(entry.Palette, entry.IsDark, terminalDark))
if entry.IsDark == terminalDark {
theme.codeTheme = codeThemeForMode(themeMode(entry.Name))
}
return themeMode(entry.Name), theme
}
// No selectable row (the filter matched nothing): fall back to the committed
// theme rather than leaving the previous preview applied.
m.restoreCommittedTheme()
}
// restoreCommittedTheme re-applies m.themeMode after a /theme preview is dismissed
// without choosing (Esc). Preview never wrote m.themeMode, so it still holds the
// real preference; re-applying repaints back to it.
func (m model) restoreCommittedTheme() {
applyTheme(m.themeMode, m.hasDarkBg)
return themeSystem, buildSystemThemeForTerminal(terminalDark)
}
// handleThemeCommand implements /theme [name]: `list` shows state, a registered
// theme name (or `auto`) switches the active palette live. Bare `/theme` opens the
// picker at the dispatch layer, so it never reaches here empty. Mirrors handleStyleCommand.
// theme name (or legacy `auto`) switches the active palette. Bare `/theme` opens
// the picker at the dispatch layer, so it never reaches here empty.
func (m model) handleThemeCommand(args string) (model, string) {
arg := strings.ToLower(strings.TrimSpace(args))
if arg == "" || arg == "list" {
@@ -126,29 +122,35 @@ func (m model) handleThemeCommand(args string) (model, string) {
if !validThemeMode(arg) {
return m, "Theme\nUnknown theme: " + arg + " (use /theme with no argument to pick from the list)"
}
m.themeMode = themeMode(arg)
resolved := applyTheme(m.themeMode, m.hasDarkBg)
active := arg
if m.themeMode == themeAuto {
active = "auto (" + string(resolved) + ")"
}
m.themeMode = resolveThemeMode(arg)
active := string(applyTheme(m.themeMode, m.hasDarkBg))
lines := []string{
"Theme",
"active theme: " + active,
"Already-printed scrollback keeps its previous colors; new output uses the new theme.",
}
// Commit path (both /theme <name> and the picker route through here): persist the
// choice so it survives restart. Previews call applyTheme directly and never reach here.
if note := m.persistThemePreference(); note != "" {
lines = append(lines, note)
}
return m, strings.Join(lines, "\n")
}
// themeAppliedNotice returns the compact confirmation used after a successful
// theme switch. The full state card remains available through /theme list.
func (m model) themeAppliedNotice() string {
if m.themeMode == themeSystem {
return "Theme: System"
}
if entry, ok := lookupTheme(string(m.themeMode)); ok {
return "Theme: " + entry.Label
}
return "Theme: " + string(m.themeMode)
}
// persistThemePreference writes the committed theme to user config so it is applied
// again at startup (via Options.SavedTheme -> resolveThemeMode). Best-effort: returns
// a short note to surface on failure, or "" on success / when there is no config
// path (e.g. tests). Never called from the live-preview path.
// path (e.g. tests).
func (m model) persistThemePreference() string {
if strings.TrimSpace(m.userConfigPath) == "" {
return ""
@@ -161,21 +163,13 @@ func (m model) persistThemePreference() string {
// themeStateText renders the /theme state view.
func (m model) themeStateText() string {
active := string(m.themeMode)
if m.themeMode == themeAuto {
bg := "light"
if m.hasDarkBg {
bg = "dark"
}
active = "auto (" + bg + ")"
}
return renderCommandOutput(commandOutput{
Title: "Theme",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "State",
Lines: []string{
"active theme: " + active,
"active theme: " + string(m.themeMode),
"available: " + strings.Join(themeModes, ", "),
},
}},
+77 -33
View File
@@ -2,6 +2,7 @@ package tui
import (
"fmt"
"image/color"
"math"
"strconv"
"strings"
@@ -124,19 +125,20 @@ func TestAllThemesContrastAndHierarchy(t *testing.T) {
}
}
// resolveThemeMode precedence: explicit flag > ZERO_THEME env > auto.
// resolveThemeMode precedence: explicit flag > ZERO_THEME env > system.
func TestResolveThemeModePrecedence(t *testing.T) {
cases := []struct {
flag, env string
want themeMode
}{
{"light", "dark", themeLight}, // flag wins
{"dark", "light", themeDark}, // flag wins
{"", "light", themeLight}, // env
{"", "dark", themeDark}, // env
{"", "", themeAuto}, // default
{"garbage", "also-bad", themeAuto},
{"AUTO", "", themeAuto},
{"light", "dark", themeSystem}, // retired preference migrates
{"dark", "light", themeSystem}, // retired preference migrates
{"", "light", themeSystem}, // retired env value migrates
{"", "dark", themeSystem}, // retired env value migrates
{"dracula", "dune", themeMode("dracula")},
{"", "", themeSystem}, // default
{"garbage", "also-bad", themeSystem},
{"AUTO", "", themeSystem},
}
for _, c := range cases {
if got := resolveThemeMode(c.flag, c.env); got != c.want {
@@ -145,7 +147,19 @@ func TestResolveThemeModePrecedence(t *testing.T) {
}
}
// applyTheme: auto resolves from background; explicit dark/light ignore it.
func TestLegacyThemeArgumentsRemainAcceptedOutsidePicker(t *testing.T) {
for _, value := range []string{"dark", "light"} {
if !ValidThemeArg(value) {
t.Errorf("ValidThemeArg(%q) = false, want true for migration compatibility", value)
}
if validThemeMode(value) {
t.Errorf("validThemeMode(%q) = true, retired values must stay out of the picker", value)
}
}
}
// applyTheme keeps system canvas-native and mirrors named palettes when their
// intended surface polarity differs from the terminal.
func TestApplyThemeResolution(t *testing.T) {
defer applyTheme(themeDark, true) // restore the global default
cases := []struct {
@@ -154,16 +168,24 @@ func TestApplyThemeResolution(t *testing.T) {
want themeMode
wantInk string
}{
{themeAuto, true, themeDark, darkPalette.ink},
{themeAuto, false, themeLight, lightPalette.ink},
{themeDark, false, themeDark, darkPalette.ink}, // explicit ignores bg
{themeLight, true, themeLight, lightPalette.ink}, // explicit ignores bg
{themeSystem, true, themeSystem, ""},
{themeAuto, false, themeSystem, ""},
{themeDark, true, themeDark, darkPalette.ink},
{themeDark, false, themeDark, invertPaletteColor(darkPalette.ink)},
{themeLight, false, themeLight, lightPalette.ink},
{themeLight, true, themeLight, invertPaletteColor(lightPalette.ink)},
}
for _, c := range cases {
got := applyTheme(c.mode, c.darkBg)
if got != c.want {
t.Errorf("applyTheme(%q, darkBg=%v) = %q, want %q", c.mode, c.darkBg, got, c.want)
}
if c.wantInk == "" {
if _, ok := zeroTheme.bgPanel.(lipgloss.NoColor); !ok {
t.Errorf("applyTheme(%q,%v): system mode painted canvas with %T", c.mode, c.darkBg, zeroTheme.bgPanel)
}
continue
}
wantR, wantG, wantB, _ := lipgloss.Color(c.wantInk).RGBA()
gotR, gotG, gotB, _ := zeroTheme.inkColor.RGBA()
if gotR != wantR || gotG != wantG || gotB != wantB {
@@ -172,6 +194,27 @@ func TestApplyThemeResolution(t *testing.T) {
}
}
func TestInvertedPalettePreservesSemanticHues(t *testing.T) {
inverted := invertedPalette(nordPalette)
for _, test := range []struct {
name string
value string
dominant int
}{
{"success", inverted.green, 1},
{"error", inverted.red, 0},
} {
r, g, b, _ := lipgloss.Color(test.value).RGBA()
channels := []uint32{r, g, b}
for index, channel := range channels {
if index != test.dominant && channels[test.dominant] <= channel {
t.Errorf("inverted %s color %s lost its semantic hue", test.name, test.value)
break
}
}
}
}
// The light palette must be a real dark-on-light set: distinct from dark, ink
// well-contrasted against the panel, accent readable, and the gray hierarchy
// (ink→faintest) ordered toward the surface so it still reads on white.
@@ -235,22 +278,23 @@ func TestLightPaletteContrastAndHierarchy(t *testing.T) {
// /theme switches the active theme live and shows state with no arg.
func TestHandleThemeCommand(t *testing.T) {
defer applyTheme(themeDark, true)
m := model{themeMode: themeAuto, hasDarkBg: true}
m := model{themeMode: themeSystem, hasDarkBg: true}
m, out := m.handleThemeCommand("light")
if m.themeMode != themeLight {
t.Fatalf("after /theme light, mode = %q", m.themeMode)
m, out := m.handleThemeCommand("dracula")
if m.themeMode != themeMode("dracula") {
t.Fatalf("after /theme dracula, mode = %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, lightPalette.ink) {
t.Error("/theme light did not swap the active palette")
if got, want := colorHex(t, zeroTheme.inkColor), draculaPalette.ink; got != want {
t.Errorf("/theme dracula ink = %s, want %s", got, want)
}
if !strings.Contains(out, "light") {
t.Errorf("output should confirm light: %q", out)
if !strings.Contains(out, "dracula") {
t.Errorf("output should confirm dracula: %q", out)
}
m, _ = m.handleThemeCommand("dark")
if m.themeMode != themeDark {
t.Fatalf("after /theme dark, mode = %q", m.themeMode)
before := m.themeMode
m, removed := m.handleThemeCommand("dark")
if m.themeMode != before || !strings.Contains(removed, "Unknown theme") {
t.Fatalf("retired dark theme should not be selectable: mode=%q output=%q", m.themeMode, removed)
}
_, state := m.handleThemeCommand("")
@@ -291,8 +335,8 @@ func TestNewThemePresetsWired(t *testing.T) {
}
// The --theme flag and ZERO_THEME both resolve through resolveThemeMode, and
// applyTheme must actually swap zeroTheme to the resolved preset's own palette,
// not silently fall back to a built-in.
// applyTheme must actually swap zeroTheme to the resolved preset, adapting only
// its contrast direction when the terminal has the opposite polarity.
func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) {
defer applyTheme(themeDark, true)
@@ -304,13 +348,13 @@ func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) {
}
applyTheme(themeMode("dune"), true)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, dunePalette.ink) {
t.Error("applying \"dune\" did not swap zeroTheme to the dune palette")
if got, want := colorHex(t, zeroTheme.inkColor), invertPaletteColor(dunePalette.ink); got != want {
t.Errorf("applying dune ink = %s, want %s", got, want)
}
applyTheme(themeMode("neon"), true)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, neonPalette.ink) {
t.Error("applying \"neon\" did not swap zeroTheme to the neon palette")
if got, want := colorHex(t, zeroTheme.inkColor), neonPalette.ink; got != want {
t.Errorf("applying neon ink = %s, want %s", got, want)
}
}
@@ -481,8 +525,8 @@ func TestExtendedThemeANSI256Contrast(t *testing.T) {
}
}
func mustR(t *testing.T, hex string) uint32 {
func colorHex(t *testing.T, value color.Color) string {
t.Helper()
r, _, _, _ := lipgloss.Color(hex).RGBA()
return r
r, g, b, _ := value.RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
+3
View File
@@ -183,6 +183,9 @@ func TestConsecutiveExplorationResultsGroup(t *testing.T) {
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)
}
if strings.Contains(got, "details") {
t.Fatalf("grouped exploration card must not advertise an inert details action, got:\n%s", got)
}
}
// looksLikeRedundantConfirmation unit coverage (verb table + multiline guard).
-51
View File
@@ -1,7 +1,6 @@
package tui
import (
"fmt"
"strings"
)
@@ -70,59 +69,9 @@ func newDefaultToolBodyRegistry() *toolBodyRegistry {
} {
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.
registry.register("update_plan", toolBodyRendererFunc(planSummaryCardBody))
return registry
}
// planSummaryCardBody collapses an update_plan card to a single status line
// (step counts) instead of the full "Current Plan:" body, which the plan panel
// already renders. Falls back to the generic body if the text isn't a plan.
func planSummaryCardBody(req toolBodyRequest) cardBody {
total, done, active, failed := 0, 0, 0, 0
for _, line := range strings.Split(req.detail, "\n") {
line = strings.TrimSpace(line)
if !isNumberedPlanLine(line) {
continue
}
total++
switch {
case strings.Contains(line, "[completed]"):
done++
case strings.Contains(line, "[in_progress]"):
active++
case strings.Contains(line, "[failed]"):
failed++
}
}
if total == 0 {
return genericCardBody(req.detail, req.opts)
}
parts := []string{fmt.Sprintf("%d steps", total)}
if done > 0 {
parts = append(parts, fmt.Sprintf("%d done", done))
}
if active > 0 {
parts = append(parts, fmt.Sprintf("%d in progress", active))
}
if failed > 0 {
parts = append(parts, fmt.Sprintf("%d failed", failed))
}
return cardBody{lines: []string{zeroTheme.faint.Render(strings.Join(parts, " · "))}}
}
// isNumberedPlanLine reports whether a line begins with "<n>." (a plan item).
func isNumberedPlanLine(line string) bool {
i := 0
for i < len(line) && line[i] >= '0' && line[i] <= '9' {
i++
}
return i > 0 && i < len(line) && line[i] == '.'
}
func newToolBodyRegistry(fallback toolBodyRenderer) *toolBodyRegistry {
if fallback == nil {
fallback = unknownToolBodyRenderer{}
+22 -3
View File
@@ -62,6 +62,24 @@ type transcriptRow struct {
final bool
turnTools int
turnElapsed time.Duration
// attachments records what accompanied a user turn without retaining the
// original bytes in transcript memory or the session log.
attachments transcriptAttachmentSummary
}
type transcriptAttachmentSummary struct {
images int
documents int
}
const (
persistedAttachmentCountLimit = 64
attachmentSummaryVisibleItems = 4
)
func (summary transcriptAttachmentSummary) empty() bool {
return summary.images <= 0 && summary.documents <= 0
}
type transcriptActionKind int
@@ -75,8 +93,9 @@ const (
)
type transcriptAction struct {
kind transcriptActionKind
text string
kind transcriptActionKind
text string
attachments transcriptAttachmentSummary
}
func initialTranscript() []transcriptRow {
@@ -91,7 +110,7 @@ func reduceTranscript(rows []transcriptRow, action transcriptAction) []transcrip
case actionClear:
return initialTranscript()
case actionAppendUser:
return appendRow(rows, rowUser, action.text)
return appendTranscriptRow(rows, transcriptRow{kind: rowUser, text: action.text, attachments: action.attachments})
case actionAppendAssistant:
return appendRow(rows, rowAssistant, action.text)
case actionAppendSystem:
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more