mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
28
Commits
main
...
feat/ui-polish
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2caa9ab93 | ||
|
|
b9894f0eca | ||
|
|
a6892f2b38 | ||
|
|
de2f071dd2 | ||
|
|
1439b25a34 | ||
|
|
00baeac3ca | ||
|
|
b6cd6c1bab | ||
|
|
99a8b9f588 | ||
|
|
889f109630 | ||
|
|
8b4605b121 | ||
|
|
09c78b527f | ||
|
|
d3d7efdcf8 | ||
|
|
94458fd0d1 | ||
|
|
b94e956893 | ||
|
|
4a289c286a | ||
|
|
6c7a5d8f2a | ||
|
|
c4de188713 | ||
|
|
0d6cc7c5a5 | ||
|
|
76fbe0421e | ||
|
|
93034797ad | ||
|
|
17ed6fd9bb | ||
|
|
f7a4158875 | ||
|
|
7ce81090a2 | ||
|
|
634aa7990e | ||
|
|
5a3a7dd0ca | ||
|
|
7b1180ea0a | ||
|
|
eee66ed0e8 | ||
|
|
e41d2e579c |
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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++ {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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++
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ", "),
|
||||
},
|
||||
}},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user