Compare commits

..
Author SHA1 Message Date
Kevin Codex 2e2c181d28 fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order
- edit_file: blockAnchorReplacer now returns every span clearing the
  similarity threshold (best first) instead of silently picking the
  single most-similar block. Hiding competitors from the cascade's
  distinct-candidate check could edit the wrong block when two blocks
  share anchors and both interiors are plausible; one clear winner still
  resolves, two plausible blocks now report ambiguity (covered by a
  two-block test that asserts the file is untouched).
- agent tests: the parallel write-barrier test now records both probes
  into one mutex-guarded shared log and asserts the cross-probe ordering
  it always claimed to (write starts after the first read batch ends;
  second read batch starts after the write ends). The previous version
  only compared indices within the read probe's own log, which a
  regression letting the write overlap a batch could still pass; the
  stray no-op mutex assignment is gone.
2026-07-05 17:16:24 +08:00
Kevin Codex 4643dc9348 fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability
- agent: serialize OnPermission behind the same mutex as
  OnPermissionRequest in the parallel read batch. buildPermissionEvent
  fires for batched reads with a non-trivial sandbox decision (e.g. a
  grant-matched extra root), and front-end handlers append to shared
  session-recording state without their own locking — two batched reads
  emitting concurrently was a data race the pre-batch serial loop never
  allowed.
- edit_file: reject two or more DISTINCT candidate spans from one fuzzy
  strategy as ambiguous instead of silently editing the first. Duplicate
  blocks at different indentation each resolve to a unique literal, so
  the previous occurrence-uniqueness check passed and wrote the wrong
  span. A single candidate occurring at multiple positions still
  cascades to stricter strategies as before.
- tools: make spill files actually reachable by the recovery path the
  truncation notice advertises — resolveScopedReadPath now recognizes
  files inside the per-uid spill dir (containment verified after symlink
  resolution, so a planted link cannot smuggle an out-of-scope file
  through the gate); read_file/grep can follow the notice instead of
  re-running the command.
2026-07-05 16:57:39 +08:00
Kevin Codex 57e34c790d fix: address PR review feedback on fuzzy edits, diagnostics, spill
- edit_file: adapt the replacement to the fuzzy-resolved span — when the
  span is a uniformly deeper-indented copy of old_string (line-trimmed /
  indentation-flexible matches), the same indentation delta is applied to
  new_string, and a span ending in a bare CR keeps its CR so CRLF files
  never gain a mixed line ending; block-anchor matches with drifted
  interiors are deliberately left untouched
- diagnostics: render workspace-relative paths in inline post-edit
  diagnostics (base name outside the workspace) so absolute paths never
  leak the local username or directory layout into the model prompt or
  session transcript
- spill: harden the spill directory for shared temp dirs — per-uid name,
  reject symlinked or non-directory paths (MkdirAll follows symlinks),
  and on Unix require current-user ownership
- bash: the branch's output cap and truncation-flag threading are
  superseded by main's capture-time bounding (boundedBuffer +
  budgetBashCapture), which already sets Result.Truncated; this rebase
  adopts that design and keeps spill-to-disk on exec_command, where the
  full output is still buffered and a spill is genuinely complete
2026-07-05 16:47:18 +08:00
Kevin Codex d0ae9c98e3 feat: agent quality, caching, retry, and tooling upgrades
Ten self-contained improvements to the core agent engine and tools:

- edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness
  protection and a disproportion guard) when old_string does not match
  byte-for-byte; exact match remains the fast path
- edit_file/write_file: inline error-severity LSP diagnostics in tool
  output so the model sees a break it introduced in the same turn
- lsp: expand server registry from 4 to 28 languages; doctor grades only
  tier-1 servers and lists the long tail informationally
- agent: consecutive read-only tool calls execute concurrently (bounded
  at 8), consumed in original order, never spanning a mutating call
- anthropic: cache_control breakpoints on the last two conversation
  messages so the transcript prefix stays a prompt-cache hit
- providerio: retry schedule raised to 6 attempts with exponential
  2s-30s backoff when the server sends no Retry-After
- tools: truncated exec/bash output spills to a redacted temp file
  referenced in the truncation notice; bash output (previously
  unbounded) now shares exec_command's token budget
- web_fetch: dependency-free HTML-to-markdown conversion (format:
  auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s)
- modelregistry: live models.dev overlay refreshes context limits and
  base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint
- tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard
  formatters across 27 extensions, ordered before the file-tracker
  re-baseline so conflict detection is preserved
2026-07-05 16:43:50 +08:00
56 changed files with 162 additions and 2877 deletions

No files matched your search

-5
View File
@@ -37,13 +37,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/zero
/zero.exe
/zero-skills
/snake-game.html
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
.superpowers/
docs/superpowers/
# Termux/Android build artifacts
zero-termux-arm64
zero-linux-sandbox
-7
View File
@@ -47,12 +47,6 @@ Tips:
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
### Personal guidelines, across every project
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\Roaming\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
## 2. Custom specialists
Specialists are Zero's sub-agents. Three scopes, in priority order:
@@ -313,7 +307,6 @@ A team that wants every contributor's Zero to behave the same way commits:
Each contributor adds only:
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
-55
View File
@@ -173,61 +173,6 @@ Linux native sandboxing also requires Bubblewrap to be installed.
macOS uses the system sandbox and does not need an extra helper binary.
### Termux (Android)
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
seccomp filter on Android:
```bash
# Install Go in Termux
pkg install golang
# Build Zero for Android
git clone https://github.com/Gitlawb/zero.git
cd zero
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
# Move into PATH
mv zero ~/.local/bin/
```
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
needs one. Use `proot` to bind-mount Termux's resolver config:
```bash
pkg install proot
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
```
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
```bash
#!/data/data/com.termux/files/usr/bin/bash
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
```
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
to avoid PRoot's ptrace interference with the 1003 escape sequence.
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
example, to use OpenCode Zen's free tier:
```bash
zero providers add opencode \
--name opencode \
--model deepseek-v4-flash-free \
--base-url https://opencode.ai/zen/v1 \
--set-active
```
Windows source builds can use the main `zero.exe` as the command runner and setup
helper through Zero's built-in self-dispatch path. If you want a release-style
layout anyway, build the standalone helper executables next to `zero.exe`:
-5
View File
@@ -41,7 +41,6 @@ events when present.
```json
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
@@ -52,10 +51,6 @@ events when present.
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
```
`reasoning` events carry live model reasoning/status deltas for providers that
stream them separately from answer text. They are liveness/progress events only:
they are not folded into `text` or the final answer.
Permission events may include structured sandbox metadata:
```json
+1 -2
View File
@@ -429,9 +429,8 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
hasToolCalls := len(collected.ToolCalls) > 0
hasVisibleText := strings.TrimSpace(collected.Text) != ""
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
if hasToolCalls || hasVisibleText || hasReasoning {
if hasToolCalls || hasVisibleText {
state.emptyTurns = 0
} else {
state.emptyTurns++
-33
View File
@@ -22,14 +22,6 @@ func textTurn(content string) []zeroruntime.StreamEvent {
}
}
// reasoningTurn produces live reasoning without visible assistant text.
func reasoningTurn(content string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventReasoning, Content: content},
{Type: zeroruntime.StreamEventDone},
}
}
// toolTurn produces a turn that calls a named tool with the given args JSON.
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
@@ -109,31 +101,6 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
}
}
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
reasoningTurn("thinking 1"),
reasoningTurn("thinking 2"),
reasoningTurn("thinking 3"),
textTurn("done"),
},
}
result, err := Run(context.Background(), "go", provider, Options{
Registry: tools.NewRegistry(),
MaxTurns: 12,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
}
if len(provider.requests) != 4 {
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
}
}
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
-5
View File
@@ -173,7 +173,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
// Report the per-category context budget for this turn so a surface can
@@ -209,7 +208,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
// Pre-content connect after a context-limit compaction: route through the
// reconnect helper so a transient upstream hiccup here doesn't fail the
@@ -270,7 +268,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryStreamErr != nil {
@@ -329,7 +326,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
Messages: copyMessages(messages),
Tools: exposed,
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
if retryErr != nil {
@@ -709,7 +705,6 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
Messages: copyMessages(finalMessages),
ReasoningEffort: options.ReasoningEffort,
PromptCacheKey: options.SessionID,
}, reconnectNoticeFor(options))
if err != nil {
return "", messages, ""
+5 -30
View File
@@ -7,18 +7,14 @@ import (
)
func TestSkillsContextCapsLongList(t *testing.T) {
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
// the 200-rune truncation) total well past the 4096-byte list budget, so the
// overflow summary must kick in while the block stays bounded.
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
skills := make([]SkillInfo, 0, 60)
for i := 0; i < 60; i++ {
skills := make([]SkillInfo, 0, 40)
for i := 0; i < 40; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > skillsContextListBudget+1000 {
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
if len(got) > 1200 {
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
@@ -29,27 +25,6 @@ func TestSkillsContextCapsLongList(t *testing.T) {
}
}
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
// budget, under which skills past ~#6 were invisible to the model and therefore
// never triggered.
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
skills := make([]SkillInfo, 0, 20)
for i := 0; i < 20; i++ {
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
}
got := skillsContext(Options{Skills: skills})
if strings.Contains(got, "more (call skill") {
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
}
for i := 0; i < 20; i++ {
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
}
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
+26 -126
View File
@@ -9,7 +9,6 @@ import (
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/repomap"
"github.com/Gitlawb/zero/internal/workspaceseed"
)
@@ -39,14 +38,6 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
// general-to-specific order.
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
// userContextFile is the per-user instruction file, resolved under
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
// (config.json, commands, specialists) so users can keep personal guidance out
// of individual repositories.
const userContextFile = "ZERO.md"
var userConfigDirForPrompt = config.UserConfigDir
// maxProjectContextBytes caps how much of a single project doc is injected so
// a large guidelines file can't blow the context budget.
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
@@ -90,13 +81,6 @@ func buildSystemPrompt(options Options) string {
if seed := workspaceSeedContext(options.Cwd); seed != "" {
sections = append(sections, seed)
}
// User guidelines are injected before workspace/project guidelines so the
// project's AGENTS.md/ZERO.md is the later, more specific instruction
// block. See userGuidelines for the explicit precedence note carried in
// the section text itself.
if user := userGuidelines(); user != "" {
sections = append(sections, user)
}
if ws := workspaceContext(options.Cwd); ws != "" {
sections = append(sections, ws)
}
@@ -191,19 +175,10 @@ func specialistDelegationContext(options Options) string {
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with an extreme number of them can't bloat every turn's prompt.
// Skills past the budget are summarized as a count rather than dropped — the
// model can still load any of them by name (an unknown name returns the full
// list from the skill tool).
//
// The budget is deliberately generous — roughly 18 skills with maximally long
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
// descriptions consume it faster since the budget counts bytes. This list is
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
// never triggers. The previous 640-byte cap silently broke invocation for
// anyone with more than ~6 skills — the model cannot match a request against a
// skill it cannot see.
const skillsContextListBudget = 4096
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
// budget are summarized as a count rather than dropped — the model can still load
// any of them by name (an unknown name returns the full list from the skill tool).
const skillsContextListBudget = 640
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
@@ -211,7 +186,7 @@ func skillsContext(options Options) string {
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
@@ -241,11 +216,9 @@ func skillsContext(options Options) string {
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget. The cap is roomy on
// purpose: the description carries the skill's trigger conditions ("use when…"),
// and truncating those is what stops the model from ever invoking the skill.
// verbose description can't dominate the skills-list budget.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 200
const maxDescRunes = 100
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
@@ -339,7 +312,13 @@ func projectGuidelines(cwd, gitRoot string) string {
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
limit = remaining
}
content = truncateGuidelineContent(content, limit)
if len(content) > limit {
cut := limit
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
content = content[:cut] + "\n… (truncated)"
}
label := projectGuidelineLabel(match, gitRoot)
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
totalUsed += len(content)
@@ -347,92 +326,6 @@ func projectGuidelines(cwd, gitRoot string) string {
return b.String()
}
// userGuidelines returns the per-user ZERO.md instructions block, if present.
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
// per-user config; the basename match is case-insensitive so a file saved as
// zero.md still resolves on case-sensitive filesystems, mirroring the project
// guideline loader. The section carries an explicit precedence note because
// this is a global, personal preferences file: it is injected earlier in the
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
// the note keeps that precedence unambiguous even if a model weighs later
// context more heavily than section order alone implies.
func userGuidelines() string {
configDir, err := userConfigDirForPrompt()
if err != nil {
return ""
}
configDir = strings.TrimSpace(configDir)
if configDir == "" {
return ""
}
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
if match == "" {
return ""
}
data, err := os.ReadFile(match)
if err != nil {
return ""
}
content := strings.TrimSpace(string(data))
if content == "" {
return ""
}
content = truncateGuidelineContent(content, maxProjectContextBytes)
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
"These are the operator's personal preferences, not project policy. " +
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
content
}
// truncationMarker is appended to guideline content that was cut short.
const truncationMarker = "\n… (truncated)"
// truncateGuidelineContent caps content at limit bytes without splitting a
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
// the marker is reserved before choosing the cut point. When limit is too
// small to fit the marker itself, the marker is dropped instead, so the
// returned string never exceeds limit for any non-negative limit.
func truncateGuidelineContent(content string, limit int) string {
if len(content) <= limit {
return content
}
if limit <= 0 {
return ""
}
cut := limit - len(truncationMarker)
if cut < 0 {
cut = 0
}
for cut > 0 && !utf8.RuneStart(content[cut]) {
cut--
}
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
return truncated
}
// limit is smaller than the marker itself: fall back to a hard,
// rune-safe cut at limit bytes with no marker rather than exceeding it.
end := limit
for end > 0 && !utf8.RuneStart(content[end]) {
end--
}
return content[:end]
}
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
// whose name matches basename case-insensitively, or "" when absent.
func findCaseInsensitiveFile(dir, basename string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
return filepath.Join(dir, e.Name())
}
}
return ""
}
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
// from cwd, the chain collapses to [cwd].
@@ -485,16 +378,23 @@ func projectGuidelineLabel(match, gitRoot string) string {
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
baseLower := strings.ToLower(filepath.Base(name))
// Walk to the file's parent through dir with case-insensitive segment
// matching, then find a regular file with the same basename. This works
// on both case-sensitive and case-insensitive filesystems and always
// returns the on-disk filename.
// matching, then find a regular file with the same lowercased
// basename. This works on both case-sensitive and case-insensitive
// filesystems and always returns the on-disk filename.
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
if !ok {
continue
}
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
return match
entries, err := os.ReadDir(parent)
if err != nil {
continue
}
for _, e := range entries {
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
return filepath.Join(parent, e.Name())
}
}
}
return ""
-4
View File
@@ -63,10 +63,6 @@ work.
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.
- 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
over-built; if a 200-line solution could be 50, rewrite it.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
-125
View File
@@ -9,25 +9,6 @@ import (
"github.com/Gitlawb/zero/internal/sandbox"
)
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
// directory for every test in this package. Without this, buildSystemPrompt
// falls back to the real config.UserConfigDir, so any developer with a
// personal ~/.config/zero/ZERO.md would get its content folded into
// otherwise-unrelated prompt assertions, making test runs non-deterministic
// across contributor machines. Tests that specifically exercise user
// guidelines stub userConfigDirForPrompt themselves via
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
if err != nil {
panic(err)
}
userConfigDirForPrompt = func() (string, error) { return dir, nil }
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
@@ -147,88 +128,6 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
return body
}
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
prompt := buildSystemPrompt(Options{})
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer concise summaries.") {
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
}
if strings.Contains(prompt, " Prefer concise summaries. ") {
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
}
}
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
prompt := buildSystemPrompt(Options{})
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer concise summaries.") {
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
}
}
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
// User guidelines are global personal preferences; project guidelines
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
// and the user section must say so explicitly so the precedence holds
// even if a model otherwise weighs later context more heavily.
configDir := t.TempDir()
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
t.Fatal(err)
}
prompt := buildSystemPrompt(Options{Cwd: cwd})
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
if userIdx < 0 || projectIdx < 0 {
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
}
if userIdx > projectIdx {
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
}
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
}
}
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
prompt := buildSystemPrompt(Options{})
if strings.Contains(prompt, "## User guidelines") {
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
}
}
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
t.Helper()
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
}
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
t.Helper()
old := userConfigDirForPrompt
userConfigDirForPrompt = fn
return func() {
userConfigDirForPrompt = old
}
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.md on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
@@ -358,30 +257,6 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
}
}
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
limit := 64
content := strings.Repeat("x", limit+1)
got := truncateGuidelineContent(content, limit)
if len(got) > limit {
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
}
if !strings.Contains(got, "truncated") {
t.Fatalf("expected truncation marker, got %q", got)
}
}
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
// Limits at or below the truncation marker's own length can't fit the
// marker at all; the helper must still never exceed the requested limit.
content := strings.Repeat("x", 100)
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
got := truncateGuidelineContent(content, limit)
if len(got) > limit {
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
}
}
}
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
root := filepath.Join("r")
leaf := filepath.Join(root, "a", "b")
-42
View File
@@ -12,7 +12,6 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/agent"
@@ -79,8 +78,6 @@ type appDeps struct {
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
@@ -182,8 +179,6 @@ func defaultAppDeps() appDeps {
runAgentEval: defaultRunAgentEval,
inspectChanges: zerogit.Inspect,
commitChanges: zerogit.Commit,
pushChanges: zerogit.Push,
createPR: zerogit.CreatePR,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
@@ -522,12 +517,6 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.commitChanges == nil {
deps.commitChanges = defaults.commitChanges
}
if deps.pushChanges == nil {
deps.pushChanges = defaults.pushChanges
}
if deps.createPR == nil {
deps.createPR = defaults.createPR
}
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
@@ -783,14 +772,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
// LoadSkills backs /skills and direct /<skill-name> invocation in the TUI.
// It resolves against the same merged set (default dir + plugin skill
// roots) as the skill tool and the system-prompt list, re-read per use so
// newly installed skills work without a restart.
LoadSkills: cachedSkillsLoader(func() []skills.Skill {
merged, _ := plugins.MergedSkillsLoaded(deps.skillsDir(), pluginActivation.skillRoots)
return merged
}),
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
@@ -1286,26 +1267,3 @@ Flags:
`)
return err
}
// cachedSkillsLoader memoizes a skills loader for a short interval. The TUI
// calls the loader from autocomplete on every "/x" keystroke; without a cache
// each keystroke would re-read every SKILL.md body. Two seconds is fresh enough
// that a newly installed skill still shows up "immediately" while typing.
func cachedSkillsLoader(load func() []skills.Skill) func() []skills.Skill {
var (
mu sync.Mutex
at time.Time
cached []skills.Skill
)
const ttl = 2 * time.Second
return func() []skills.Skill {
mu.Lock()
defer mu.Unlock()
if cached != nil && time.Since(at) < ttl {
return cached
}
cached = load()
at = time.Now()
return cached
}
}
-1
View File
@@ -544,7 +544,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
EnabledTools: options.enabledTools,
DisabledTools: options.disabledTools,
OnText: writer.text,
OnReasoning: writer.reasoning,
OnToolCall: func(call agent.ToolCall) {
writer.toolCall(call, registry)
sessionRecorder.append(sessions.EventToolCall, map[string]any{
+3 -59
View File
@@ -529,45 +529,6 @@ func TestRunExecStreamJSONRunStartUsesResolvedAPIModel(t *testing.T) {
}
}
func TestRunExecStreamJSONEmitsReasoningEvents(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
cwd := t.TempDir()
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--output-format", "stream-json", "think then answer"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return reasoningExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
events := decodeJSONLines(t, stdout.String())
reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
if reasoning["delta"] != "Thinking. " {
t.Fatalf("unexpected reasoning event: %#v", reasoning)
}
textIdx, text := findJSONEventIndex(t, events, "text")
if text["delta"] != "done" {
t.Fatalf("unexpected text event: %#v", text)
}
if reasoningIdx >= textIdx {
t.Fatalf("expected reasoning event before text event, got indices %d and %d", reasoningIdx, textIdx)
}
final := findJSONEvent(t, events, "final")
if final["text"] != "done" {
t.Fatalf("reasoning must not be folded into final answer: %#v", final)
}
}
func TestExecEventWriterTruncatesStreamJSONToolResults(t *testing.T) {
var stdout bytes.Buffer
writer := execEventWriter{
@@ -790,17 +751,6 @@ type toolCallingExecProvider struct {
answer string
}
type reasoningExecProvider struct{}
func (reasoningExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
ch := make(chan zeroruntime.StreamEvent, 3)
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventReasoning, Content: "Thinking. "}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "done"}
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(ch)
return ch, nil
}
func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
for _, message := range request.Messages {
if message.Role == zeroruntime.MessageRoleTool {
@@ -827,19 +777,13 @@ func (provider toolCallingExecProvider) StreamCompletion(ctx context.Context, re
func findJSONEvent(t *testing.T, events []map[string]any, eventType string) map[string]any {
t.Helper()
_, event := findJSONEventIndex(t, events, eventType)
return event
}
func findJSONEventIndex(t *testing.T, events []map[string]any, eventType string) (int, map[string]any) {
t.Helper()
for idx, event := range events {
for _, event := range events {
if event["type"] == eventType {
return idx, event
return event
}
}
t.Fatalf("event %q not found in %#v", eventType, events)
return -1, nil
return nil
}
func findSessionEvent(t *testing.T, events []sessions.Event, eventType sessions.EventType) sessions.Event {
-10
View File
@@ -80,16 +80,6 @@ func (writer *execEventWriter) text(delta string) {
writer.writeStdout(delta)
}
func (writer *execEventWriter) reasoning(delta string) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{"type": "reasoning", "delta": delta})
return
}
if writer.format == execOutputStreamJSON {
writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventReasoning, RunID: writer.runID, Delta: delta})
}
}
func (writer *execEventWriter) toolCall(call agent.ToolCall, registry *tools.Registry) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
-9
View File
@@ -88,15 +88,6 @@ func formatRepoInfo(info repoinfo.Info) string {
fmt.Fprintf(&b, "Files: %d\n", info.FileCount)
fmt.Fprintf(&b, "Directories: %d (max depth %d)\n", info.DirectoryCount, info.MaxDepth)
fmt.Fprintf(&b, "Est. LOC: %d\n", info.LOCEstimate)
if info.CommitCount != nil {
fmt.Fprintf(&b, "Commits: %d\n", *info.CommitCount)
}
if info.BranchCount != nil {
fmt.Fprintf(&b, "Branches: %d\n", *info.BranchCount)
}
if info.TagCount != nil {
fmt.Fprintf(&b, "Tags: %d\n", *info.TagCount)
}
if info.PrimaryLanguage != "" {
fmt.Fprintf(&b, "Primary lang: %s\n", info.PrimaryLanguage)
}
+1 -5
View File
@@ -33,7 +33,7 @@ func TestFormatRepoInfoText(t *testing.T) {
contrib := 3
info := repoInfoTestData(age, contrib)
out := formatRepoInfo(info)
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3", "Commits: 100", "Branches: 5", "Tags: 10"} {
for _, want := range []string{"Files", "TypeScript", "main", "GitHub Actions", "42", "3"} {
if !strings.Contains(out, want) {
t.Fatalf("text output missing %q:\n%s", want, out)
}
@@ -113,16 +113,12 @@ func initTempGitRepo(t *testing.T) string {
}
func repoInfoTestData(age, contrib int) repoinfo.Info {
commits := 100
branches := 5
tags := 10
return repoinfo.Info{
FileCount: 10, DirectoryCount: 3, MaxDepth: 2, LOCEstimate: 500,
Languages: []repoinfo.LangStat{{Name: "TypeScript", LOCEstimate: 400, FileCount: 4}},
PrimaryLanguage: "TypeScript", LanguageCount: 1,
WorkspaceType: "none", CICD: []string{"GitHub Actions"},
HasGit: true, Branch: "main", AgeDays: &age, Contributors90d: &contrib,
CommitCount: &commits, BranchCount: &branches, TagCount: &tags,
}
}
+2 -195
View File
@@ -41,13 +41,6 @@ type changesCommandOptions struct {
hasMessage bool
dryRun bool
maxDiffBytes int
remote string
force bool
title string
body string
fill bool
draft bool
yes bool
auto bool
}
@@ -273,10 +266,6 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
return exitCrash
}
return exitSuccess
case "push":
return runChangesPush(args, stdout, stderr, deps)
case "pr", "pull-request":
return runChangesPR(args, stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown changes command %q", command))
}
@@ -476,41 +465,6 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.maxDiffBytes = maxDiffBytes
case arg == "--remote":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.remote = strings.TrimSpace(value)
index = next
case strings.HasPrefix(arg, "--remote="):
options.remote = strings.TrimSpace(strings.TrimPrefix(arg, "--remote="))
case arg == "--force":
options.force = true
case arg == "--title":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.title = value
index = next
case strings.HasPrefix(arg, "--title="):
options.title = strings.TrimPrefix(arg, "--title=")
case arg == "--body":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
return options, false, err
}
options.body = value
index = next
case strings.HasPrefix(arg, "--body="):
options.body = strings.TrimPrefix(arg, "--body=")
case arg == "--fill":
options.fill = true
case arg == "--draft":
options.draft = true
case arg == "--yes":
options.yes = true
case arg == "-a" || arg == "--auto":
options.auto = true
case strings.HasPrefix(arg, "-"):
@@ -519,30 +473,15 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
if command != "commit" && options.message != "" {
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
}
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
}
if command == "commit" && options.hasMessage && options.auto {
return options, false, execUsageError{"cannot specify both --message and --auto"}
}
if command != "commit" && command != "push" && options.dryRun {
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
}
if command != "inspect" && options.baseRef != "" {
if command == "commit" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
}
if command != "push" && command != "pr" && (options.remote != "" || options.force) {
return options, false, execUsageError{"--remote and --force are only valid with push or pr"}
}
if command != "pr" && (options.title != "" || options.body != "" || options.fill || options.draft) {
return options, false, execUsageError{"--title, --body, --fill, and --draft are only valid with `zero changes pr`"}
}
if command != "push" && command != "pr" && options.yes {
return options, false, execUsageError{"--yes is only valid with push or pr"}
}
return options, false, nil
}
@@ -820,24 +759,14 @@ func writeChangesHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero changes inspect [flags]
zero changes commit [flags]
zero changes push [flags]
zero changes pr [flags]
Inspects, commits, pushes, and creates pull requests for local git changes.
Inspects local git changes and optionally creates a commit.
Flags:
-C, --cwd <path> Workspace directory
--base <ref> Diff against <ref>...HEAD instead of the working tree
--diff-bytes <n> Maximum diff bytes to include
-m, --message <text> Commit message for `+"`zero changes commit`"+`
--dry-run Preview commit metadata / push without mutating git state
--remote <name> Remote to push to (defaults to upstream tracked branch or origin)
--force Use force-with-lease when pushing
--title <text> PR title
--body <text> PR body
--fill Automatically populate PR title and body from commits
--draft Create PR as a draft
--yes Confirm pushing to a default/protected branch
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
--dry-run Preview commit metadata without mutating git state
--json Print JSON output
@@ -846,128 +775,6 @@ Flags:
return err
}
func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseChangesArgs(args, "push")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeChangesHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: options.remote,
Force: options.force,
DryRun: options.dryRun,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if options.json {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
dryRunStr := ""
if options.dryRun {
dryRunStr = " (dry run)"
}
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s%s\n", result.Branch, result.Remote, dryRunStr); err != nil {
return exitCrash
}
if result.Output != "" {
if _, err := fmt.Fprintln(stdout, result.Output); err != nil {
return exitCrash
}
}
return exitSuccess
}
func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseChangesArgs(args, "pr")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeChangesHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
if !options.fill && options.title == "" {
return writeExecUsageError(stderr, "must provide either --fill or --title to run non-interactively")
}
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
return exitCrash
}
}
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: options.remote,
Force: options.force,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("auto-push failed: %v", err))
}
if !options.json {
if _, err := fmt.Fprintf(stdout, "Pushed branch %s to remote %s\n", pushResult.Branch, pushResult.Remote); err != nil {
return exitCrash
}
}
prResult, err := deps.createPR(context.Background(), zerogit.PROptions{
Cwd: workspaceRoot,
Fill: options.fill,
Draft: options.draft,
Title: options.title,
Body: options.body,
})
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if options.json {
type prJSONResult struct {
Branch string `json:"branch"`
Remote string `json:"remote"`
Output string `json:"output"`
}
res := prJSONResult{
Branch: pushResult.Branch,
Remote: pushResult.Remote,
Output: strings.TrimSpace(prResult.Output),
}
if err := writePrettyJSON(stdout, res); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, strings.TrimSpace(prResult.Output)); err != nil {
return exitCrash
}
return exitSuccess
}
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
var promptBuilder strings.Builder
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
+2 -25
View File
@@ -9,7 +9,6 @@ import (
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -284,10 +283,10 @@ func (provider *Provider) emitChunk(
events chan<- zeroruntime.StreamEvent,
) {
for _, choice := range chunk.Choices {
if reasoning := choice.Delta.reasoningText(); reasoning != "" {
if choice.Delta.ReasoningContent != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: reasoning,
Content: choice.Delta.ReasoningContent,
})
}
if choice.Delta.Content != "" {
@@ -318,13 +317,6 @@ func (provider *Provider) emitChunk(
}
}
func (delta streamDelta) reasoningText() string {
if delta.ReasoningContent != "" {
return delta.ReasoningContent
}
return delta.Reasoning
}
// mapFinishReason maps OpenAI's finish_reason onto the runtime's normalized
// terminal reasons. A normal finish ("stop"/"tool_calls"/"") returns "".
func mapFinishReason(reason string) string {
@@ -422,12 +414,6 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
mapped.ReasoningEffort = effort
}
// prompt_cache_key is a documented OpenAI parameter; compatible servers
// ignore unknown fields, but a strict endpoint that rejects it can be
// accommodated with ZERO_DISABLE_PROMPT_CACHE_KEY=1.
if key := strings.TrimSpace(request.PromptCacheKey); key != "" && !promptCacheKeyDisabled() {
mapped.PromptCacheKey = key
}
if len(request.Tools) > 0 {
mapped.Tools = make([]toolDefinition, 0, len(request.Tools))
for _, tool := range request.Tools {
@@ -444,15 +430,6 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
return mapped
}
// promptCacheKeyDisabled reports whether the ZERO_DISABLE_PROMPT_CACHE_KEY
// kill switch is set to a truthy value. "0" and "false" (any case) are
// no-ops, matching how ZERO_FORMAT_ON_WRITE parses boolean flags, so an
// explicitly-disabled toggle never flips the behavior it names.
func promptCacheKeyDisabled() bool {
value := strings.TrimSpace(os.Getenv("ZERO_DISABLE_PROMPT_CACHE_KEY"))
return value != "" && value != "0" && !strings.EqualFold(value, "false")
}
// openAIReasoningEffort normalizes a requested effort to a value the OpenAI chat
// completions API accepts, or "" to omit the field. "none" (and anything else)
// is dropped rather than risking a 400 on an unrecognized enum.
@@ -227,39 +227,6 @@ func TestStreamCompletionEmitsReasoningContentDeltas(t *testing.T) {
}
}
func TestStreamCompletionEmitsReasoningAliasDeltas(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Thinking. "}}]}`)
writeSSE(w, `{"choices":[{"delta":{"reasoning":"Answering now."}}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
if len(reasoning) != 2 {
t.Fatalf("reasoning events = %#v, want two reasoning deltas", reasoning)
}
if reasoning[0].Content != "Thinking. " || reasoning[1].Content != "Answering now." {
t.Fatalf("unexpected reasoning events: %#v", reasoning)
}
if text := eventsOfType(events, zeroruntime.StreamEventText); len(text) != 0 {
t.Fatalf("reasoning must not emit text events, got %#v", text)
}
}
func TestStreamCompletionPrefersReasoningContentOverAlias(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"standard","reasoning":"alias"}}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
if len(reasoning) != 1 || reasoning[0].Content != "standard" {
t.Fatalf("reasoning events = %#v, want standard reasoning_content", reasoning)
}
}
func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"reasoning_content":"Thinking. ","content":"Answer."}}]}`)
@@ -1259,61 +1226,3 @@ func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
}
}
// TestOpenAIRequestPromptCacheKey locks in prompt_cache_key forwarding: a
// session-carrying request serializes the key so the backend can route to a
// replica holding the cached prefix, a keyless request omits the field
// entirely (strict servers see byte-identical requests to before), and
// ZERO_DISABLE_PROMPT_CACHE_KEY suppresses it for endpoints that reject it.
func TestOpenAIRequestPromptCacheKey(t *testing.T) {
provider, err := New(Options{Model: "gpt-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}}
req := provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
PromptCacheKey: "sess_123",
})
if req.PromptCacheKey != "sess_123" {
t.Fatalf("PromptCacheKey = %q, want sess_123", req.PromptCacheKey)
}
data, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(data), `"prompt_cache_key":"sess_123"`) {
t.Fatalf("prompt_cache_key not serialized: %s", data)
}
req = provider.openAIRequest(zeroruntime.CompletionRequest{Messages: messages})
if data, err = json.Marshal(req); err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(data), "prompt_cache_key") {
t.Fatalf("keyless request must omit prompt_cache_key: %s", data)
}
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", "1")
req = provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
PromptCacheKey: "sess_123",
})
if req.PromptCacheKey != "" {
t.Fatalf("kill switch ignored; PromptCacheKey = %q", req.PromptCacheKey)
}
// Explicitly-falsy kill switch values must NOT disable forwarding — only
// truthy values flip the toggle (same parsing as ZERO_FORMAT_ON_WRITE).
for _, value := range []string{"0", "false", "FALSE"} {
t.Setenv("ZERO_DISABLE_PROMPT_CACHE_KEY", value)
req = provider.openAIRequest(zeroruntime.CompletionRequest{
Messages: messages,
PromptCacheKey: "sess_123",
})
if req.PromptCacheKey != "sess_123" {
t.Fatalf("ZERO_DISABLE_PROMPT_CACHE_KEY=%q must be a no-op; PromptCacheKey = %q", value, req.PromptCacheKey)
}
}
}
-6
View File
@@ -8,11 +8,6 @@ type chatCompletionRequest struct {
ReasoningEffort string `json:"reasoning_effort,omitempty"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
// PromptCacheKey asks the backend to route the request to a replica that
// already holds this conversation's prefix in its prompt cache (the OpenAI
// `prompt_cache_key` parameter). Omitted when the caller carries no session
// identity or when ZERO_DISABLE_PROMPT_CACHE_KEY is set.
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
}
// streamOptions requests the final usage chunk on a streaming response. Without
@@ -83,7 +78,6 @@ type streamChoice struct {
type streamDelta struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []streamToolCallDelta `json:"tool_calls"`
}
-50
View File
@@ -54,9 +54,6 @@ type Info struct {
AgeDays *int `json:"ageDays,omitempty"`
Contributors90d *int `json:"contributors90d,omitempty"`
CommitVelocity30d *int `json:"commitVelocity30d,omitempty"`
BranchCount *int `json:"branchCount,omitempty"`
CommitCount *int `json:"commitCount,omitempty"`
TagCount *int `json:"tagCount,omitempty"`
}
// RunGit runs a git subcommand in dir and returns raw stdout. A non-zero exit
@@ -232,43 +229,6 @@ func Collect(ctx context.Context, opts Options) (Info, error) {
info.CommitVelocity30d = &n
}
}
if count, err := run(ctx, dir, "rev-list", "--count", "HEAD"); err == nil {
if n, perr := strconv.Atoi(strings.TrimSpace(count)); perr == nil {
info.CommitCount = &n
}
}
if branches, err := run(ctx, dir, "branch", "-a"); err == nil {
set := map[string]bool{}
for _, line := range strings.Split(branches, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.Contains(line, "->") {
continue
}
// Strip leading '* ' (current branch) or '+ ' (checked out in another worktree).
line = strings.TrimPrefix(line, "* ")
line = strings.TrimPrefix(line, "+ ")
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "(") {
continue
}
// If it's a remote branch, extract the branch name.
// E.g., remotes/origin/main -> main
// E.g., remotes/origin/feature/xyz -> feature/xyz
if strings.HasPrefix(line, "remotes/") {
line = strings.TrimPrefix(line, "remotes/")
if slashIdx := strings.Index(line, "/"); slashIdx != -1 {
line = line[slashIdx+1:]
}
}
set[line] = true
}
n := len(set)
info.BranchCount = &n
}
if tags, err := run(ctx, dir, "tag"); err == nil {
n := countNonEmptyLines(tags)
info.TagCount = &n
}
return info, nil
}
@@ -368,13 +328,3 @@ func defaultRunGit(ctx context.Context, dir string, args ...string) (string, err
}
return stdout.String(), nil
}
func countNonEmptyLines(s string) int {
count := 0
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}
+1 -13
View File
@@ -43,8 +43,6 @@ func TestCollectCoreMetrics(t *testing.T) {
"remote": "https://github.com/x/y.git\n",
"log": "0\n",
"rev-list": "12\n",
"branch": " main\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature\n",
"tag": "v0.1.0\nv0.2.0\n",
}, nil),
})
if err != nil {
@@ -87,15 +85,6 @@ func TestCollectCoreMetrics(t *testing.T) {
if info.CommitVelocity30d == nil || *info.CommitVelocity30d != 12 {
t.Fatalf("CommitVelocity30d=%v want 12", info.CommitVelocity30d)
}
if info.CommitCount == nil || *info.CommitCount != 12 {
t.Fatalf("CommitCount=%v want 12", info.CommitCount)
}
if info.BranchCount == nil || *info.BranchCount != 2 {
t.Fatalf("BranchCount=%v want 2", info.BranchCount)
}
if info.TagCount == nil || *info.TagCount != 2 {
t.Fatalf("TagCount=%v want 2", info.TagCount)
}
}
func TestCollectContributorsUnique(t *testing.T) {
@@ -263,13 +252,12 @@ func TestCollectNotGitRepo(t *testing.T) {
}
func TestCollectOnlyReadOnlyLocalSubcommands(t *testing.T) {
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true, "branch": true, "tag": true}
allowed := map[string]bool{"ls-tree": true, "rev-parse": true, "remote": true, "log": true, "rev-list": true}
var used []string
_, err := Collect(context.Background(), Options{
Now: time.Unix(0, 0),
RunGit: fakeGit(t, map[string]string{
"ls-tree": lsTree, "rev-parse": "main\n", "remote": "u\n", "log": "0\n", "rev-list": "1\n",
"branch": "main\n", "tag": "v0.1.0\n",
}, &used),
})
if err != nil {
+2 -49
View File
@@ -3,9 +3,8 @@ package sandbox
import (
"errors"
"os"
"path/filepath"
"os/exec"
"runtime"
"strings"
)
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
@@ -122,55 +121,9 @@ func (manager SandboxManager) Backend() Backend {
return manager.backend
}
// isExecutable checks whether a file is executable. On Unix, this checks the
// execute permission bits. On Windows, Go's os.FileMode does not set execute
// bits, so we check for common executable extensions instead.
func isExecutable(fi os.FileInfo) bool {
if !fi.Mode().IsRegular() {
return false
}
if runtime.GOOS == "windows" {
name := strings.ToLower(fi.Name())
return strings.HasSuffix(name, ".exe") ||
strings.HasSuffix(name, ".com") ||
strings.HasSuffix(name, ".bat") ||
strings.HasSuffix(name, ".cmd")
}
return fi.Mode()&0o111 != 0
}
// lookupExecutable checks whether a named binary exists and is executable,
// using os.Stat instead of exec.LookPath. exec.LookPath internally uses the
// faccessat2 syscall, which is blocked by Android's seccomp filter (SIGSYS).
func lookupExecutable(name string) (string, error) {
if !strings.Contains(name, string(os.PathSeparator)) {
path := os.Getenv("PATH")
for _, dir := range strings.Split(path, string(os.PathListSeparator)) {
if dir == "" {
continue // skip empty entries (e.g. trailing colon)
}
candidate := filepath.Join(dir, name)
if fi, err := os.Stat(candidate); err == nil {
if isExecutable(fi) {
return candidate, nil
}
}
}
return "", errors.New("executable file not found in $PATH")
}
fi, err := os.Stat(name)
if err != nil {
return "", err
}
if isExecutable(fi) {
return name, nil
}
return "", errors.New("executable file not found")
}
func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend {
if lookup == nil {
lookup = lookupExecutable
lookup = exec.LookPath
}
switch goos {
case "linux":
@@ -190,67 +190,6 @@ func TestWindowsUnelevatedRealSandboxSmoke(t *testing.T) {
}
}
// TestWindowsRestrictedTokenNestedPipeCapture pins the fix in
// broadenWindowsRestrictedTokenDefaultDacl (windows_token_windows.go): a
// nested subprocess spawned FROM WITHIN the sandboxed process, with its
// output captured via a pipe, must succeed. Before the fix, the sandboxed
// process's own attempt to create a pipe for such a subprocess failed with
// ERROR_ACCESS_DENIED (Win32 error 5) — the WRITE_RESTRICTED token's extra
// access check has no restricted-SID match against a freshly created pipe's
// default security descriptor. This is exactly the pattern any tool that
// shells out internally and captures output hits (`gh` invoking `git`, for
// one; cmd.exe's own FOR /F does the identical CreatePipe+CreateProcess
// dance internally, so it reproduces the bug with no external dependency
// beyond cmd.exe itself).
func TestWindowsRestrictedTokenNestedPipeCapture(t *testing.T) {
if os.Getenv("ZERO_SANDBOX_REAL_SMOKE") != "1" {
t.Skip("set ZERO_SANDBOX_REAL_SMOKE=1 to run real Windows sandbox smoke tests")
}
runnerExe := realSmokeExecutable(t, "ZERO_WINDOWS_COMMAND_RUNNER_EXE", WindowsSandboxCommandRunnerName)
root := t.TempDir()
sandboxHome := filepath.Join(root, ".zero-sandbox")
profile := PermissionProfile{
FileSystem: FileSystemPolicy{
Kind: FileSystemRestricted,
ReadRoots: []string{root},
WriteRoots: []WritableRoot{{Root: root, ProtectedMetadataNames: []string{".git", ".zero", ".agents"}}},
IncludePlatformRoots: true,
AllowTemp: true,
},
Network: NetworkPolicy{Mode: NetworkDeny},
}
config := WindowsSandboxCommandArgsOptions{
SandboxHome: sandboxHome,
CommandCWD: root,
WorkspaceRoots: []string{root},
PermissionProfile: profile,
SandboxLevel: WindowsSandboxLevelUnelevated,
}
marker := filepath.Join(root, "nested-pipe-marker.txt")
script := filepath.Join(root, "nested-pipe.cmd")
// FOR /F drives cmd.exe's own internal CreatePipe+CreateProcess capture
// of the quoted command's output — the exact mechanism this fix targets.
// The full path to whoami.exe sidesteps PATH resolution inside the
// sandboxed process's minimal environment.
scriptBody := "for /F %%i in ('C:\\Windows\\System32\\whoami.exe') do echo %%i> " + marker + "\r\n"
if err := os.WriteFile(script, []byte(scriptBody), 0o644); err != nil {
t.Fatalf("write nested-pipe script: %v", err)
}
runWindowsRealSmokeCommand(t, runnerExe, config, []string{
"cmd.exe", "/d", "/s", "/c", script,
}, 0)
captured, err := os.ReadFile(marker)
if err != nil {
t.Fatalf("read nested-pipe marker: %v", err)
}
if strings.TrimSpace(string(captured)) == "" {
t.Fatalf("nested-pipe marker is empty; FOR /F failed to capture the subprocess's output")
}
}
func realSmokeExecutable(t *testing.T, envKey string, fallbackName string) string {
t.Helper()
if path := os.Getenv(envKey); path != "" {
-135
View File
@@ -21,11 +21,6 @@ const (
var procCreateRestrictedToken = windows.NewLazySystemDLL("advapi32.dll").NewProc("CreateRestrictedToken")
// procSetEntriesInAclW merges new ACEs into an existing ACL. Not wrapped by
// x/sys/windows (its internal setEntriesInAcl is unexported), so it is
// declared directly the same way procCreateRestrictedToken is.
var procSetEntriesInAclW = windows.NewLazySystemDLL("advapi32.dll").NewProc("SetEntriesInAclW")
type windowsLocalSID struct {
sid *windows.SID
}
@@ -127,139 +122,9 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w
_ = restricted.Close()
return 0, err
}
if err := broadenWindowsRestrictedTokenDefaultDacl(restricted, sidFromBytes(logonSID)); err != nil {
_ = restricted.Close()
return 0, err
}
runtime.KeepAlive(logonSID)
return restricted, nil
}
// broadenWindowsRestrictedTokenDefaultDacl adds the token's own logon SID to
// its default DACL (the DACL applied to any kernel object the token's process
// creates WITHOUT an explicit security descriptor, e.g. anonymous pipes,
// events, mutexes).
//
// A WRITE_RESTRICTED token requires a WRITE-type access check to match BOTH
// the normal enabled-SID grant AND a SEPARATE grant to one of the token's
// restricted SIDs. CreateRestrictedToken does not touch the default DACL it
// inherits from the base token, so newly created objects only carry ACEs for
// the base token's normal identity (owner, SYSTEM, Administrators) — none of
// which are in the restricted-SID list — so the second check fails. This
// breaks anything that creates a pipe/handle for itself with default
// security, which is exactly what any tool using a language runtime's
// "spawn a subprocess and capture its output" primitive does (Go's os/exec,
// and by extension `gh`, and many other CLI tools that shell out
// internally): CreatePipe() succeeds, but the creating process's own token
// then fails to actually read/write the handle it just made.
//
// The logon SID is already unconditionally present in this token's
// restricted-SID list (see createWindowsRestrictedTokenFromBase), so adding
// it here — and ONLY it — to the default DACL is the minimal change that
// satisfies the WRITE_RESTRICTED extra check for self-created objects. It
// does not touch file-system access: NTFS write-jailing is enforced by the
// explicit ACL grants applied to workspace paths (windows_acl.go), which are
// created WITH an explicit security descriptor and never consult the token's
// default DACL. The exposure is bounded to the same logon session (other
// processes running as the same signed-in user could, in principle, open a
// NAMED kernel object the sandboxed process creates with default security;
// anonymous pipes — the actual object type this fixes — have no name and are
// only reachable via an inherited handle, so this is a no-op for them).
func broadenWindowsRestrictedTokenDefaultDacl(token windows.Token, logonSID *windows.SID) error {
oldDacl, oldDaclBuf, err := windowsTokenDefaultDacl(token)
if err != nil {
return fmt.Errorf("read token default DACL: %w", err)
}
// The second (WRITE-type) access check only needs a read/write match; a
// full GENERIC_ALL grant (which also implies DELETE, WRITE_DAC, and
// WRITE_OWNER) is broader than the pipe/event/mutex use case this exists
// for actually requires.
access := []windows.EXPLICIT_ACCESS{{
AccessPermissions: windows.GENERIC_READ | windows.GENERIC_WRITE,
AccessMode: windows.GRANT_ACCESS,
Inheritance: windows.NO_INHERITANCE,
Trustee: windows.TRUSTEE{
MultipleTrusteeOperation: windows.NO_MULTIPLE_TRUSTEE,
TrusteeForm: windows.TRUSTEE_IS_SID,
TrusteeType: windows.TRUSTEE_IS_USER,
TrusteeValue: windows.TrusteeValueFromSID(logonSID),
},
}}
newDacl, err := setEntriesInACL(access, oldDacl)
// oldDacl points INTO oldDaclBuf (see windowsTokenDefaultDacl) and is
// dereferenced by native code inside setEntriesInACL, above. Nothing in Go
// references oldDaclBuf once windowsTokenDefaultDacl returned, so without
// this KeepAlive it is eligible for GC the moment an allocation in this
// function (the EXPLICIT_ACCESS literal, TrusteeValueFromSID) hits a
// safepoint, before the native call ever reads it - a real, if narrow,
// use-after-free window in security-boundary code.
runtime.KeepAlive(oldDaclBuf)
if err != nil {
return fmt.Errorf("merge token default DACL: %w", err)
}
defer func() { _, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(newDacl))) }()
if err := windowsSetTokenDefaultDacl(token, newDacl); err != nil {
return fmt.Errorf("set token default DACL: %w", err)
}
return nil
}
type windowsTokenDefaultDaclInfo struct {
DefaultDacl *windows.ACL
}
// windowsTokenDefaultDacl returns the token's current default DACL along with
// the buffer backing it. TOKEN_DEFAULT_DACL embeds the ACL data inside the
// same allocation GetTokenInformation fills, so the returned *windows.ACL is
// only valid for as long as the returned buffer stays reachable: the caller
// must runtime.KeepAlive(buf) until every native call that dereferences the
// ACL has completed. A KeepAlive inside this function, ending at its own
// return, would not protect any use by the caller after that.
func windowsTokenDefaultDacl(token windows.Token) (*windows.ACL, []byte, error) {
var size uint32
err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, nil, 0, &size)
if err == nil || err != windows.ERROR_INSUFFICIENT_BUFFER {
return nil, nil, fmt.Errorf("size TokenDefaultDacl: %w", err)
}
buf := make([]byte, size)
if err := windows.GetTokenInformation(token, windows.TokenDefaultDacl, &buf[0], size, &size); err != nil {
return nil, nil, fmt.Errorf("get TokenDefaultDacl: %w", err)
}
info := (*windowsTokenDefaultDaclInfo)(unsafe.Pointer(&buf[0]))
return info.DefaultDacl, buf, nil
}
func windowsSetTokenDefaultDacl(token windows.Token, dacl *windows.ACL) error {
info := windowsTokenDefaultDaclInfo{DefaultDacl: dacl}
err := windows.SetTokenInformation(token, windows.TokenDefaultDacl, (*byte)(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info)))
runtime.KeepAlive(info)
return err
}
// setEntriesInACL merges entries into oldACL via Win32 SetEntriesInAclW,
// returning a newly allocated ACL the caller must free with windows.LocalFree.
func setEntriesInACL(entries []windows.EXPLICIT_ACCESS, oldACL *windows.ACL) (*windows.ACL, error) {
if len(entries) == 0 {
return nil, errors.New("setEntriesInACL requires at least one entry")
}
var newACL *windows.ACL
// SetEntriesInAclW reports failure directly in its return value (ERROR_SUCCESS
// on success, a Win32 error code otherwise); unlike many Win32 APIs it does not
// set the thread's last-error, so the syscall trio's second/third return
// values (which surface GetLastError) are not meaningful here.
ret, _, _ := procSetEntriesInAclW.Call(
uintptr(len(entries)),
uintptr(unsafe.Pointer(&entries[0])),
uintptr(unsafe.Pointer(oldACL)),
uintptr(unsafe.Pointer(&newACL)),
)
runtime.KeepAlive(entries)
if ret != 0 {
return nil, fmt.Errorf("SetEntriesInAclW: %w", syscall.Errno(ret))
}
return newACL, nil
}
func copyWindowsLogonSID(token windows.Token) ([]byte, error) {
groups, err := token.GetTokenGroups()
if err == nil {
-1
View File
@@ -21,7 +21,6 @@ type EventType string
const (
EventRunStart EventType = "run_start"
EventText EventType = "text"
EventReasoning EventType = "reasoning"
EventToolCall EventType = "tool_call"
EventPermission EventType = "permission"
EventPermissionRequest EventType = "permission_request"
+2 -5
View File
@@ -8,7 +8,6 @@ import (
"unicode/utf8"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
const (
@@ -238,10 +237,8 @@ func renderAssistantMarkdownPlainText(text string, proseMeasure int, tableMeasur
}
func stripMarkdownRenderControls(text string) string {
// renderAssistantMarkdownText embeds markdownBoldStart/markdownBoldEnd
// markers for prose text, and highlightCodeAuto embeds ANSI color sequences
// for fenced code blocks. Strip everything so clipboard text is pure plain text.
return ansi.Strip(text)
text = strings.ReplaceAll(text, markdownBoldStart, "")
return strings.ReplaceAll(text, markdownBoldEnd, "")
}
func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
+1 -48
View File
@@ -188,54 +188,7 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
})
out = append(out, m.matchUserCommandSuggestions(token)...)
out = append(out, m.matchSkillSuggestions(token)...)
// Each source caps itself, but the merged list must honor the shared cap too
// (three sources could otherwise stack up to ~3x the palette bound).
if len(out) > maxCommandSuggestions {
out = out[:maxCommandSuggestions]
}
return out
}
// matchSkillSuggestions returns installed skills whose slash name has the typed
// prefix, so skills are discoverable and invocable from the palette like user
// commands. Precedence (builtin > user command > skill) is enforced here by
// skipping any skill whose name is claimed by a builtin (or alias) or a user
// command — unlike the builtin/user pair, a shadowed skill row would be dead at
// dispatch time, so it must not be advertised at all.
func (m model) matchSkillSuggestions(token string) []commandSuggestion {
prefix := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(token, "/")))
if prefix == "" || m.loadSkills == nil {
return nil
}
taken := m.takenSlashNames()
var out []commandSuggestion
// Read through the loader (not the startup snapshot in agentOptions) so the
// palette matches what dispatch will actually resolve — a skill installed or
// removed mid-session appears/disappears without a restart.
for _, skill := range m.installedSkills() {
name := skillSlashName(skill.Name)
if name == "" || taken[name] || !strings.HasPrefix(name, prefix) {
continue
}
// Two skills whose names collide after lowercasing (e.g. "Deploy" and
// "deploy") map to one slash name; only the first — the one dispatch will
// run — may be advertised, or the row's description and the executed
// instructions silently diverge.
taken[name] = true
desc := strings.TrimSpace(skill.Description)
if desc == "" {
desc = "Skill: /" + name
} else {
desc += " (skill)"
}
out = append(out, commandSuggestion{Name: "/" + name, Desc: desc})
if len(out) >= maxCommandSuggestions {
break
}
}
return out
return append(out, m.matchUserCommandSuggestions(token)...)
}
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
-16
View File
@@ -698,19 +698,3 @@ func (m model) debugText() string {
}},
})
}
// skillsText is the /skills fallback when NO skills are installed — an install
// hint. With skills present /skills opens the searchable skill picker instead
// (see newSkillPicker), matching how /model works.
func (m model) skillsText() string {
return renderCommandOutput(commandOutput{
Title: "Skills",
Status: commandStatusInfo,
Sections: []commandSection{{
Lines: []string{"No skills installed."},
}},
Hints: []string{
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
},
})
}
-8
View File
@@ -46,7 +46,6 @@ const (
commandCopy
commandExport
commandNew
commandSkills
commandUnknown
)
@@ -132,13 +131,6 @@ var commandDefinitions = []commandDefinition{
description: "List registered tools.",
kind: commandTools,
},
{
name: "/skills",
usage: "/skills",
group: commandGroupTools,
description: "List installed skills; run one directly with /<skill-name> [args].",
kind: commandSkills,
},
{
name: "/context",
usage: "/context",
+1 -1
View File
@@ -280,7 +280,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
}
func (m model) composerMouseSelectionBlocked() bool {
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
return m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
}
+1 -50
View File
@@ -143,7 +143,7 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
if len(items) != 1 {
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
}
@@ -264,55 +264,6 @@ func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
}
}
// TestDetailedTranscriptClosesFileView: entering detailed transcript mode
// closes an active file drill-in so the full transcript body replaces the file
// content.
func TestDetailedTranscriptClosesFileView(t *testing.T) {
m := filesPanelTestModel()
m.altScreen = true
m = m.openFileView("web/app.js")
if !m.fileView.active {
t.Fatal("sanity check: openFileView should activate the file view")
}
updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)
if m.fileView.active {
t.Fatal("detailed transcript should close the file drill-in")
}
if !m.transcriptDetailed {
t.Fatal("Ctrl+O should enter detailed mode")
}
items := m.transcriptBodyItems(m.chatColumnWidth(), "", true)
if len(items) <= 1 {
t.Fatalf("detailed transcript should show multiple body items, got %d", len(items))
}
}
// TestDetailedTranscriptStaysClosedOnSecondToggle: toggling out and back into
// detailed mode does not re-open the closed file view.
func TestDetailedTranscriptStaysClosedOnSecondToggle(t *testing.T) {
m := filesPanelTestModel()
m.altScreen = true
m = m.openFileView("web/app.js")
updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)
updated, _ = m.Update(testKeyCtrl('o'))
m = updated.(model)
if m.fileView.active {
t.Fatal("exiting detailed mode must not re-open the file drill-in")
}
if m.transcriptDetailed {
t.Fatal("second Ctrl+O should exit detailed mode")
}
}
// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
// (Esc exiting the view instead of reaching the prompt's deny handling).
+1 -1
View File
@@ -372,7 +372,7 @@ func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
return 0, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "", false)
items := m.transcriptBodyItems(width, "")
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
startY := -1
for _, span := range metrics.spans {
+24 -169
View File
@@ -29,7 +29,6 @@ import (
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
@@ -63,7 +62,6 @@ type model struct {
ctx context.Context
cwd string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
@@ -737,7 +735,6 @@ func newModel(ctx context.Context, options Options) model {
cwd: cwd,
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
loadSkills: options.LoadSkills,
composerCursorVisible: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
@@ -1357,7 +1354,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// home screen or a narrow terminal. Hiding reflows the chat to full
// width, so mirror the width-change bookkeeping (re-wrap the streaming
// fade, resize the composer) the WindowSizeMsg path does.
if !m.transcriptDetailed && m.noBlockingModal() && m.sidebarToggleAllowed() {
if m.noBlockingModal() && m.sidebarToggleAllowed() {
// Just show/hide — no transcript notice. The reflow IS the feedback,
// and emitting a line every toggle piled up noise in the chat.
m.sidebarHidden = !m.sidebarHidden
@@ -1424,87 +1421,24 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case keyIs(msg, tea.KeyPgUp):
if m.transcriptDetailed {
return m, nil
}
// A stationary mouse over a bodyY-keyed transcript hover target would
// otherwise stay lit at the scrolled-away bodyY until the next real
// motion event (see clearHover) — same reasoning as the wheel-scroll
// cases in mouse.go.
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyPgDown):
if m.transcriptDetailed {
return m, nil
}
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
case keyShift(msg) && keyIs(msg, tea.KeyUp):
// Shift+Up scrolls the transcript up one line. Must be checked before
// plain KeyUp so shifted arrows aren't consumed by the composer-path.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(-1), nil
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
return m, nil
}
if m.suggestionsActive() {
break
}
if m.composerValue() != "" {
break // let the input handle multiline navigation
}
m = m.clearHover()
return m.scrollChat(1), nil
case keyShift(msg) && keyIs(msg, tea.KeyDown):
// Shift+Down scrolls the transcript down one line. Must be checked
// before plain KeyDown so shifted arrows aren't consumed.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(1), nil
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
return m, nil
}
if m.suggestionsActive() {
break
}
if m.composerValue() != "" {
break // let the input handle multiline navigation
}
m = m.clearHover()
return m.scrollChat(-1), nil
case keyIs(msg, tea.KeyDown):
if m.transcriptDetailed {
m = m.clearHover()
return m.scrollChat(-1), nil
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
@@ -1546,8 +1480,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.transcriptDetailed {
m = m.clearHover()
return m.scrollChat(1), nil
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
@@ -1581,48 +1514,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.historyRecallActive() {
return m.recallHistory(-1), nil
}
case keyCtrl(msg, 'u'):
// Ctrl+U scrolls up half a page, or moves the cursor up in
// permission/ask-user prompts. Falls through to the active modal
// (wizard, etc.) when none of the above are in focus.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(-1), nil
}
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
break
}
if m.composerValue() != "" {
break // let the input handle its own Ctrl+U (delete-to-bol)
}
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyCtrl(msg, 'd'):
// Ctrl+D scrolls down half a page, or moves the cursor down in
// permission/ask-user prompts. Falls through to the active modal
// when none of the above are in focus.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(1), nil
}
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
break
}
if m.composerValue() != "" {
break // let the input handle its own Ctrl+D (delete-next-char)
}
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
}
if m.transcriptDetailed {
return m, nil
@@ -2324,23 +2215,14 @@ func (m model) View() tea.View {
}
view.ReportFocus = m.notifier != nil
if m.wantsMouseCapture() {
if isRunningUnderPRoot() {
// Under PRoot the AllMotion (1003) sequence doesn't work
// reliably, breaking touch-gesture scrolling. Fall back to
// CellMotion which still delivers wheel events, clicks, and
// drag — the only thing lost is hover-highlighting.
view.MouseMode = tea.MouseModeCellMotion
} else {
// AllMotion (not CellMotion) is required for hover highlighting:
// it reports cursor movement even with no button pressed.
// CellMotion only reports motion while a button is held (drag) —
// see bubbletea's MouseMode docs. AllMotion has marginally worse
// terminal compatibility but is well supported by the terminals
// this app targets; the existing 15ms mouse-event throttle
// (mouseEventThrottleInterval) already bounds the redraw rate
// from the extra motion events.
view.MouseMode = tea.MouseModeAllMotion
}
// AllMotion (not CellMotion) is required for hover highlighting: it
// reports cursor movement even with no button pressed. CellMotion only
// reports motion while a button is held (drag) — see bubbletea's
// MouseMode docs. AllMotion has marginally worse terminal compatibility
// but is well supported by the terminals this app targets; the existing
// 15ms mouse-event throttle (mouseEventThrottleInterval) already bounds
// the redraw rate from the extra motion events.
view.MouseMode = tea.MouseModeAllMotion
}
return view
}
@@ -2415,7 +2297,7 @@ func (m model) transcriptView() string {
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
emptyOverlay = viewportOverlay
}
bodyItems := m.transcriptBodyItems(width, emptyOverlay, false)
bodyItems := m.transcriptBodyItems(width, emptyOverlay)
footer := m.footerView(width)
@@ -2455,7 +2337,7 @@ func (m model) twoColumnTranscriptView() string {
width := chatW
suggestionOverlay := m.suggestionOverlay(width)
bodyItems := m.transcriptBodyItems(width, "", false)
bodyItems := m.transcriptBodyItems(width, "")
footer := m.footerView(width)
overlayForViewport := suggestionOverlay
if m.transcriptEmpty() && !m.pending {
@@ -2885,15 +2767,7 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
return transcriptViewport{}, false
}
width := m.chatColumnWidth()
if m.transcriptDetailed {
items := m.transcriptBodyItems(width, "", true)
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
footer := m.detailedTranscriptFooter(width)
frame := m.scrollableTranscriptFrame(header, footer)
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
}
items := m.transcriptBodyItems(width, "", false)
items := m.transcriptBodyItems(width, "")
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
@@ -3733,11 +3607,6 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerSkill:
// Fill the composer with "/name " so the user adds their request before
// submitting (a bare second Enter runs it without one); names the slash
// path cannot reach run immediately instead.
m, cmd = m.chooseSkillFromPicker(item)
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
@@ -3853,15 +3722,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
case commandTools:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
return m, nil
case commandSkills:
// With skills installed, /skills opens a searchable picker (like /model);
// the text card remains only as the no-skills install hint.
if picker := m.newSkillPicker(); picker != nil {
m.picker = picker
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
return m, nil
case commandMCP:
if strings.TrimSpace(command.text) == "" {
return m.openMCPManager(), nil
@@ -4057,11 +3917,6 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
if next, cmd, handled := m.handleUserCommand(command.text); handled {
return next, cmd
}
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
// (deterministic invocation, vs waiting for the model to pull it in).
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
return next, cmd
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "unknown command: " + command.text,
+7 -7
View File
@@ -2142,7 +2142,7 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
width := m.chatColumnWidth()
findRow := func(mm model) (transcriptBodyItem, bool) {
for _, it := range mm.transcriptBodyItems(width, "", false) {
for _, it := range mm.transcriptBodyItems(width, "") {
if it.kind == transcriptBodyItemRow && it.rowIndex >= 0 {
return it, true
}
@@ -2198,7 +2198,7 @@ func TestReasoningAfterToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowReasoning, text: "Considering the next step in detail before acting"},
transcriptRow{kind: rowToolResult, id: "t2", tool: "read_file", status: tools.StatusOK, detail: "x\ny"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
reasoningIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
@@ -2223,7 +2223,7 @@ func TestAssistantNarrationBeforeToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowAssistant, text: "I'll inspect the existing file, then run it."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n 1 | print('x')"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
toolIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
@@ -2247,7 +2247,7 @@ func TestUserPromptBeforeToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowUser, text: "create the landing page"},
transcriptRow{kind: rowToolResult, id: "t1", tool: "write_file", status: tools.StatusOK, detail: "--- /dev/null\n+++ b/index.html\n@@ -0,0 +1,1 @@\n+<!DOCTYPE html>"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
toolIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
@@ -2273,7 +2273,7 @@ func TestAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
transcriptRow{kind: rowAssistant, text: "Done.", final: true},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
finalIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
@@ -2307,7 +2307,7 @@ func TestStreamingAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
transcriptRow{kind: rowAssistant, text: "I'll run it first."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
pendingIdx := -1
for i := range items {
if items[i].kind == transcriptBodyItemPendingInterim {
@@ -2357,7 +2357,7 @@ func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
t.Fatalf("expected no body gutter at width %d, got %d", width, gutter)
}
items := m.transcriptBodyItems(width, "", false)
items := m.transcriptBodyItems(width, "")
var row *transcriptBodyItem
for i := range items {
if items[i].kind == transcriptBodyItemRow && items[i].rowIndex >= 0 {
+2 -32
View File
@@ -1,36 +1,6 @@
package tui
import (
"os"
"strings"
"sync"
tea "charm.land/bubbletea/v2"
)
// parseTracerPid returns true when /proc/self/status contains a non-zero
// TracerPid. This detects any ptrace tracer (PRoot, gdb, strace, dlv, etc.),
// not PRoot specifically. That's intentional: under any ptrace tracer the
// AllMotion (1003) sequence is unreliable, so CellMotion is the safer
// fallback. The only loss is hover-highlighting, which has no functional
// impact.
func parseTracerPid(data []byte) bool {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "TracerPid:") {
pid := strings.TrimSpace(line[10:])
return pid != "0"
}
}
return false
}
var isRunningUnderPRoot = sync.OnceValue(func() bool {
data, err := os.ReadFile("/proc/self/status")
if err != nil {
return false
}
return parseTracerPid(data)
})
import tea "charm.land/bubbletea/v2"
type mouseOverlayHit struct {
x int
@@ -287,7 +257,7 @@ func (m model) syncMouseCapture() (model, tea.Cmd) {
}
func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
if !m.altScreen || m.height <= 0 || m.transcriptDetailed {
if !m.altScreen || m.height <= 0 {
return false
}
width := m.chatColumnWidth()
-56
View File
@@ -920,30 +920,6 @@ func TestMouseCaptureOnlyDuringInteractiveSetupStages(t *testing.T) {
}
}
// TestComposerMouseSelectionBlockedInDetailedMode: composer mouse hit-testing
// is disabled while the detailed transcript view is active, so wheel and click
// events in the composer area reach the transcript body instead.
func TestComposerMouseSelectionBlockedInDetailedMode(t *testing.T) {
m := mouseTestModel()
m.input.SetValue("some text")
updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)
if !m.transcriptDetailed {
t.Fatal("sanity check: Ctrl+O should enter detailed mode")
}
if !m.composerMouseSelectionBlocked() {
t.Fatal("composerMouseSelectionBlocked should be true in detailed mode")
}
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.composerRect.y)) {
t.Fatal("mouseOverComposer should return false in detailed mode")
}
}
func firstTranscriptTextMouseY(t *testing.T, m model) int {
t.Helper()
_, y := firstTranscriptTextMousePoint(t, m)
@@ -1010,38 +986,6 @@ func withSidebarContent(m model) model {
return m
}
func TestParseTracerPidMissing(t *testing.T) {
if got := parseTracerPid([]byte("Pid: 123\nName: foo\n")); got {
t.Fatal("missing TracerPid should return false")
}
}
func TestParseTracerPidZero(t *testing.T) {
if got := parseTracerPid([]byte("TracerPid: 0\nName: foo\n")); got {
t.Fatal("TracerPid: 0 should return false")
}
}
func TestParseTracerPidNonZero(t *testing.T) {
if got := parseTracerPid([]byte("Name: foo\nTracerPid: 42\n")); !got {
t.Fatal("TracerPid: 42 should return true")
}
}
func TestParseTracerPidMalformed(t *testing.T) {
// Non-numeric values are treated as non-zero since any non-"0" string
// indicates a tracer is attached, matching the function's semantics.
if got := parseTracerPid([]byte("TracerPid: abc\n")); !got {
t.Fatal("malformed TracerPid should return true (non-zero string)")
}
}
func TestParseTracerPidEmpty(t *testing.T) {
if got := parseTracerPid([]byte{}); got {
t.Fatal("empty input should return false")
}
}
func setupMouseTestModel() model {
m := newModel(context.Background(), Options{
Setup: SetupOptions{
+1 -8
View File
@@ -13,7 +13,6 @@ import (
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -49,13 +48,7 @@ type Options struct {
SessionCompactor SessionCompactor
PrService *PrService
AgentOptions agent.Options
// LoadSkills returns the installed skills (default skills dir merged with any
// plugin skill roots), bodies included, for /skills and direct /<skill-name>
// invocation. Called lazily per use so newly installed skills are picked up
// without a restart. Nil means the session has no skills wiring (skills stay
// model-pulled via the skill tool only).
LoadSkills func() []skills.Skill
AgentOptions agent.Options
PermissionMode agent.PermissionMode
ReasoningEffort modelregistry.ReasoningEffort
ResponseStyle string
-67
View File
@@ -309,70 +309,3 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
t.Fatalf("network prompt should render target label, got %q", got)
}
}
// TestPermissionCursorCtrlU ensures Ctrl+U moves the permission cursor UP
// (toward the first option). Regression: the original implementation moved
// the cursor DOWN on Ctrl+U and UP on Ctrl+D.
func TestPermissionCursorCtrlU(t *testing.T) {
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
m.pendingPermission.cursor = 2 // middle option
updated, _ := m.Update(testKeyCtrl('u'))
next := updated.(model)
if next.pendingPermission.cursor != 1 {
t.Fatalf("Ctrl+U moved cursor to %d, want 1 (one step up)", next.pendingPermission.cursor)
}
}
// TestPermissionCursorCtrlD ensures Ctrl+D moves the permission cursor DOWN
// (toward the last option). Regression: the original implementation moved
// the cursor UP on Ctrl+D and DOWN on Ctrl+U.
func TestPermissionCursorCtrlD(t *testing.T) {
m := pendingPermissionModel(t, func(agent.PermissionDecision) {})
m.pendingPermission.cursor = 0 // first option
updated, _ := m.Update(testKeyCtrl('d'))
next := updated.(model)
if next.pendingPermission.cursor != 1 {
t.Fatalf("Ctrl+D moved cursor to %d, want 1 (one step down)", next.pendingPermission.cursor)
}
}
// TestShiftUpComposerGuard ensures Shift+Up does NOT scroll the transcript
// when the composer has text, so it falls through to the input's own line
// navigation.
func TestShiftUpComposerGuard(t *testing.T) {
m := mouseTestModel()
// Add enough transcript rows so scrolling is valid.
for i := 0; i < 20; i++ {
m.transcript = appendRow(m.transcript, rowAssistant, "line")
}
m.input.SetValue("some text")
m.chatScrollOffset = 5
updated, cmd := m.Update(testKeyShift(tea.KeyUp))
next := updated.(model)
_ = cmd
if got := next.chatScrollOffset; got != 5 {
t.Fatalf("Shift+Up with non-empty composer scrolled offset to %d, want 5 (unchanged)", got)
}
}
// TestShiftDownComposerGuard ensures Shift+Down does NOT scroll the transcript
// when the composer has text, falling through to the input's navigation.
func TestShiftDownComposerGuard(t *testing.T) {
m := mouseTestModel()
// Add enough transcript rows so scrolling is valid.
for i := 0; i < 20; i++ {
m.transcript = appendRow(m.transcript, rowAssistant, "line")
}
m.input.SetValue("some text")
m.chatScrollOffset = 3
updated, cmd := m.Update(testKeyShift(tea.KeyDown))
next := updated.(model)
_ = cmd
if got := next.chatScrollOffset; got != 3 {
t.Fatalf("Shift+Down with non-empty composer scrolled offset to %d, want 3 (unchanged)", got)
}
}
-30
View File
@@ -24,7 +24,6 @@ const (
pickerEffort
pickerSession
pickerTheme
pickerSkill
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -226,35 +225,6 @@ func (m model) newModelPicker() *commandPicker {
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// newSkillPicker lists the installed skills for browse-and-run: type to filter,
// arrow keys to select, Enter runs the chosen skill immediately. Selection is by
// the skill's EXACT name (item.Value), so even skills the slash path cannot
// reach — names shadowed by a builtin or user command, or names that don't fit
// a slash token — are runnable here: picking from the Skills picker is
// unambiguous. Returns nil when no skills are installed (the caller falls back
// to the install-hint card).
func (m model) newSkillPicker() *commandPicker {
installed := m.installedSkills()
items := make([]pickerItem, 0, len(installed))
for _, skill := range installed {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
label := name
if slash := skillSlashName(name); slash != "" {
// Show the slash form where one exists — it doubles as documentation
// of the direct "/name [args]" invocation.
label = "/" + slash
}
items = append(items, pickerItem{Label: label, Value: name, Meta: strings.TrimSpace(skill.Description)})
}
if len(items) == 0 {
return nil
}
return &commandPicker{kind: pickerSkill, title: "pick a skill · ⏎ fills /name — add your request", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// modelPickerProviders returns the providers to list in /model: all saved
// providers, falling back to the active profile when none were threaded in.
func (m model) modelPickerProviders() []config.ProviderProfile {
-44
View File
@@ -511,50 +511,6 @@ func TestSelectableAssistantRowKeepsMarkdownSemanticsPlain(t *testing.T) {
}
}
func TestStripMarkdownRenderControlsStripsAllANSI(t *testing.T) {
// Bold markers that renderAssistantMarkdownText embeds for prose formatting.
withBold := "\x1b[1mhello\x1b[22m world"
if got := stripMarkdownRenderControls(withBold); got != "hello world" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBold, got, "hello world")
}
// Color sequences from highlightCodeAuto for fenced code blocks.
withColor := "\x1b[38;2;236;236;238mdef\x1b[0m hello():"
if got := stripMarkdownRenderControls(withColor); got != "def hello():" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withColor, got, "def hello():")
}
// Sequences that combine both (bold + underline + color).
withBoldColor := "\x1b[1;4;38;2;236;236;238mW\x1b[m"
if got := stripMarkdownRenderControls(withBoldColor); got != "W" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBoldColor, got, "W")
}
}
func TestSelectedTranscriptTextStripsANSIFromHighlightedCode(t *testing.T) {
m := limeTestModel()
m.width, m.height = 120, 40
m.altScreen = true
m.headerPrinted = true
code := `package main
import "fmt"
func main() {
fmt.Println("hello")
}`
row := transcriptRow{kind: rowAssistant, text: "First, here's the code:\n\n```go\n" + code + "\n```\n\nThat's it.", final: true}
m.transcript = append(m.transcript, row)
m.flushed = len(m.transcript)
// The highlighted code block contains ANSI color sequences from chroma;
// verify none survive in the selectable text used for clipboard copy.
_, selectable := m.renderSelectableAssistantRow(0, row, 72, 0)
for _, line := range selectable {
if ansiPattern.MatchString(line.text) {
t.Fatalf("selectable text leaked ANSI escapes: %q", line.text)
}
}
}
func TestFinalAnswerRendersPlainTextAndCompletionLine(t *testing.T) {
m := limeTestModel()
row := transcriptRow{
+1 -1
View File
@@ -82,7 +82,7 @@ func (m model) sidebarToggleAllowed() bool {
// would have any visible effect. The subchat drill-in keeps its own single-column
// view, so the sidebar is suppressed there.
func (m model) sidebarAvailable() bool {
if !m.altScreen || m.height <= 0 || m.subchat.active || m.transcriptDetailed {
if !m.altScreen || m.height <= 0 || m.subchat.active {
return false
}
if sidebarWidth(m.width) <= 0 {
-205
View File
@@ -1,205 +0,0 @@
package tui
import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/skills"
)
// Skill slash invocation: "/<skill-name> [args]" runs an installed skill
// directly, mirroring user commands (.zero/commands). Skills were previously
// model-pulled only (the skill tool), which made invocation a matter of model
// discretion; typing the skill's name makes it deterministic. Precedence is
// builtin > user command > skill: parseCommand resolves builtins first, and the
// commandUnknown fallback tries user commands before skills.
// handleSkillCommand resolves a "/name args" that wasn't a builtin or a user
// command against the installed skills. On a match it launches a normal agent
// turn whose prompt inlines the skill body (its instructions) followed by the
// typed args, returning handled=true. handled=false means no skill matched, so
// the caller falls through to "unknown command".
func (m model) handleSkillCommand(raw string) (model, tea.Cmd, bool) {
name, args := splitUserCommand(raw)
if name == "" {
return m, nil, false
}
skill, ok := m.lookupSkillCommand(name)
if !ok {
return m, nil, false
}
body := strings.TrimSpace(skill.Content)
if body == "" {
// The name matched a real skill, so falling through to "unknown command"
// would mislead; surface the actual problem instead.
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill /" + name + " has an empty SKILL.md body (" + skill.Path + ")",
})
return m, nil, true
}
return m.launchOrDeferExpandedPrompt(raw, skillInvocationPrompt(body, args))
}
// bareSkillInvocationNote is appended when a skill is invoked with no request.
// The body alone is instructions with no target ("review the PR" — which PR?),
// and without this note the model improvises one instead of asking. The wording
// is conditional so self-contained skills (no target needed) still just run.
const bareSkillInvocationNote = "The user invoked this skill directly without providing a request. " +
"If these instructions need a target or details that are not already clear from the conversation " +
"(which pull request, file, branch, topic, …), ask for them first — do not guess or pick one yourself. " +
"If the instructions are self-contained, proceed."
// skillInvocationPrompt builds the agent prompt for a skill invocation: the
// skill body (its instructions), then either the user's request or — for a bare
// invocation — the ask-first note above. Mirrors usercommands.Expand's
// no-placeholder behavior for the args case.
func skillInvocationPrompt(body, args string) string {
if args != "" {
return body + "\n\n" + args
}
return body + "\n\n" + bareSkillInvocationNote
}
// launchOrDeferExpandedPrompt applies the same run-state guards the plain
// commandPrompt path has to an expanded (user command / skill) prompt: while
// exiting nothing may start, a prompt submitted mid-run is queued for the next
// turn, and compaction-in-flight warns instead of racing the compactor. The
// EXPANDED prompt is what gets queued — the queue flush path resubmits text as
// a literal prompt, so queuing the raw "/name args" would send it to the model
// as prose instead of re-dispatching it. raw is the typed invocation; on the
// compaction warning it is restored into the composer for an easy re-submit
// (the commandUnknown dispatch cleared the composer before we got here, unlike
// the plain-prompt path whose guard fires before the clear). Empty raw (the
// picker path has no typed form) restores nothing.
func (m model) launchOrDeferExpandedPrompt(raw, prompt string) (model, tea.Cmd, bool) {
if m.exiting {
return m, nil, true
}
if m.pending {
return m.queueMessage(prompt), nil, true
}
if m.compactInFlight {
if strings.TrimSpace(raw) != "" {
m.input.SetValue(raw)
m.input.CursorEnd()
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Compact\nstatus: warning\nCompaction is running. Re-run the command when it finishes.",
})
return m, nil, true
}
next, teaCmd := m.launchPrompt(prompt)
return next, teaCmd, true
}
// takenSlashNames returns every slash token already claimed by a builtin
// command (or alias) or a user command — the names a typed "/x" would dispatch
// to before ever reaching a skill.
func (m model) takenSlashNames() map[string]bool {
taken := map[string]bool{}
for _, command := range commandDefinitions {
taken[strings.TrimPrefix(command.name, "/")] = true
for _, alias := range command.aliases {
taken[strings.TrimPrefix(alias, "/")] = true
}
}
for _, cmd := range m.userCommands {
taken[cmd.Name] = true
}
return taken
}
// chooseSkillFromPicker handles Enter on a skill-picker row. Most skills want a
// request ("review WHICH pr?"), so selection fills the composer with
// "/name " — cursor at the end — for the user to complete and submit (Enter
// again runs it bare). Skills whose names cannot be typed as a slash command
// (non-slash shapes, or names shadowed by a builtin or user command) fall back
// to running immediately: the picker is the only path that reaches them, and an
// unfillable composer would be a dead end.
func (m model) chooseSkillFromPicker(item pickerItem) (model, tea.Cmd) {
slash := skillSlashName(item.Value)
if slash == "" || m.takenSlashNames()[slash] {
return m.invokeSkillByName(item.Value)
}
m.input.SetValue("/" + slash + " ")
m.input.CursorEnd()
return m, nil
}
// invokeSkillByName runs the installed skill with the given EXACT raw name (the
// skill-picker path). The same run-state guards as slash invocation apply; the
// picker offers no args affordance, so the prompt is the skill body alone.
func (m model) invokeSkillByName(name string) (model, tea.Cmd) {
for _, skill := range m.installedSkills() {
if strings.TrimSpace(skill.Name) != name {
continue
}
body := strings.TrimSpace(skill.Content)
if body == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill " + name + " has an empty SKILL.md body (" + skill.Path + ")",
})
return m, nil
}
next, teaCmd, _ := m.launchOrDeferExpandedPrompt("", skillInvocationPrompt(body, ""))
return next, teaCmd
}
// The picker row came from a slightly older load (TTL cache) and the skill
// has since been removed.
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "skill " + name + " is no longer installed",
})
return m, nil
}
// lookupSkillCommand returns the installed skill whose slash name matches the
// given (lowercased) name. Linear scan over a fresh load — skills are re-read
// per invocation so a skill installed mid-session is invocable without a
// restart, and the body is never held on the model.
func (m model) lookupSkillCommand(name string) (skills.Skill, bool) {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return skills.Skill{}, false
}
for _, skill := range m.installedSkills() {
if skillSlashName(skill.Name) == name {
return skill, true
}
}
return skills.Skill{}, false
}
// installedSkills returns the session's installed skills (default dir merged
// with plugin skill roots) via the injected loader, or nil when the session has
// no skills wiring (e.g. bare test models).
func (m model) installedSkills() []skills.Skill {
if m.loadSkills == nil {
return nil
}
return m.loadSkills()
}
// skillSlashName maps a skill's frontmatter name to its slash-command form:
// lowercased, and only if it fits the slash-token shape (letters, digits,
// dot/underscore/hyphen — a superset of user-command names, since skill names
// are free-form frontmatter). Returns "" for names that cannot be typed as a
// /command (e.g. containing spaces); those skills remain loadable by the model
// via the skill tool and are still listed by /skills.
func skillSlashName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return ""
}
for _, r := range name {
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
if !valid {
return ""
}
}
return name
}
-412
View File
@@ -1,412 +0,0 @@
package tui
import (
"context"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/skills"
)
// newSkillTestModel builds a model with an injected skills loader (and the
// matching palette metadata), no filesystem involved.
func newSkillTestModel(t *testing.T, installed ...skills.Skill) model {
t.Helper()
infos := make([]agent.SkillInfo, 0, len(installed))
for _, s := range installed {
infos = append(infos, agent.SkillInfo{Name: s.Name, Description: s.Description})
}
return newModel(context.Background(), Options{
Cwd: t.TempDir(),
LoadSkills: func() []skills.Skill { return installed },
AgentOptions: agent.Options{Skills: infos},
})
}
func submitInput(t *testing.T, m model, input string) model {
t.Helper()
m.input.SetValue(input)
updated, _ := m.Update(testKey(tea.KeyEnter))
return updated.(model)
}
func TestSkillCommandInvokesAndSubmits(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{
Name: "deploy-checks",
Description: "Run the pre-deploy verification suite.",
Content: "Run every verification step and summarize failures.",
Path: "/skills/deploy-checks/SKILL.md",
})
next := submitInput(t, m, "/deploy-checks ship v2 to production")
if transcriptContains(next.transcript, "unknown command") {
t.Fatalf("an installed skill must not be 'unknown', got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "Run every verification step and summarize failures.") {
t.Fatalf("skill body should be inlined into the prompt, got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "ship v2 to production") {
t.Fatalf("typed args should follow the skill body, got %#v", next.transcript)
}
}
func TestSkillCommandWithoutArgs(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
next := submitInput(t, m, "/reviewer")
if !transcriptContains(next.transcript, "Review the diff for correctness.") {
t.Fatalf("bare skill invocation should submit the body, got %#v", next.transcript)
}
// A bare invocation carries the ask-first note: instructions with no target
// must make the model ask ("which PR?") instead of improvising one.
if !transcriptContains(next.transcript, "ask for them first") {
t.Fatalf("bare invocation should carry the clarify-first note, got %#v", next.transcript)
}
}
// An invocation WITH a request must not carry the ask-first note — the user
// already said what to apply the skill to.
func TestSkillCommandWithArgsHasNoClarifyNote(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
next := submitInput(t, m, "/reviewer PR 484 reconnect changes")
if transcriptContains(next.transcript, "ask for them first") {
t.Fatalf("args invocation must not carry the clarify note, got %#v", next.transcript)
}
}
// A skill's frontmatter name is free-form; invocation matches its lowercased
// slash form.
func TestSkillCommandNameIsCaseInsensitive(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "Deploy-Checks", Content: "Check things."})
next := submitInput(t, m, "/deploy-checks")
if !transcriptContains(next.transcript, "Check things.") {
t.Fatalf("uppercase frontmatter name should be invocable lowercased, got %#v", next.transcript)
}
}
// Precedence: a user command (.zero/commands) shadows a same-named skill.
func TestUserCommandShadowsSkill(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "greet.md", "Say hello from the user command.")
m := newModel(context.Background(), Options{
Cwd: root,
LoadSkills: func() []skills.Skill { return []skills.Skill{{Name: "greet", Content: "Skill body must not run."}} },
})
next := submitInput(t, m, "/greet")
if !transcriptContains(next.transcript, "Say hello from the user command.") {
t.Fatalf("user command should win the name collision, got %#v", next.transcript)
}
if transcriptContains(next.transcript, "Skill body must not run.") {
t.Fatalf("shadowed skill must not run, got %#v", next.transcript)
}
}
// An empty skill body is a real, named match — surface the problem instead of
// falling through to a misleading "unknown command".
func TestSkillCommandEmptyBody(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "hollow", Path: "/skills/hollow/SKILL.md"})
next := submitInput(t, m, "/hollow")
if transcriptContains(next.transcript, "unknown command") {
t.Fatalf("an empty skill must not be 'unknown', got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "empty SKILL.md body") {
t.Fatalf("empty-body error should be surfaced, got %#v", next.transcript)
}
}
func TestUnknownSlashStillUnknownWithSkillsInstalled(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
next := submitInput(t, m, "/definitelynotaskill")
if !transcriptContains(next.transcript, "unknown command") {
t.Fatalf("non-matching name should still be unknown, got %#v", next.transcript)
}
}
func TestSkillSlashName(t *testing.T) {
cases := map[string]string{
"deploy-checks": "deploy-checks",
"Deploy-Checks": "deploy-checks",
" pdf_tools ": "pdf_tools",
"v2.migrate": "v2.migrate",
"has space": "",
"emoji✨": "",
"": "",
"slash/inside": "",
"tab\tseparated": "",
}
for in, want := range cases {
if got := skillSlashName(in); got != want {
t.Errorf("skillSlashName(%q) = %q, want %q", in, got, want)
}
}
}
func TestSkillAppearsInAutocomplete(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"})
got := m.matchCommandSuggestions("/dep")
found := false
for _, s := range got {
if s.Name == "/deploy-checks" {
found = true
if s.Desc != "Run the pre-deploy suite. (skill)" {
t.Fatalf("skill suggestion should be labeled, got %q", s.Desc)
}
}
}
if !found {
t.Fatalf("skill /deploy-checks should appear for '/dep', got %#v", got)
}
}
// A skill shadowed by a builtin (or its alias) or a user command would be dead
// at dispatch time, so the palette must not advertise it.
func TestShadowedSkillNotSuggested(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "deploy.md", "User deploy.")
installed := []skills.Skill{
{Name: "help", Content: "x"}, // builtin collision
{Name: "sessions", Content: "x"}, // builtin ALIAS collision (/resume alias)
{Name: "deploy", Content: "x"}, // user-command collision
}
infos := make([]agent.SkillInfo, 0, len(installed))
for _, s := range installed {
infos = append(infos, agent.SkillInfo{Name: s.Name})
}
m := newModel(context.Background(), Options{
Cwd: root,
LoadSkills: func() []skills.Skill { return installed },
AgentOptions: agent.Options{Skills: infos},
})
// Assert on the advertised NAME with a skill-marked Desc, not on the exact
// fallback Desc format — the invariant is that no skill row carries a
// builtin/user-command name, regardless of description wording.
for _, token := range []string{"/hel", "/ses", "/dep"} {
for _, s := range m.matchSkillSuggestions(token) {
if s.Name == "/help" || s.Name == "/sessions" || s.Name == "/deploy" {
t.Fatalf("shadowed skill advertised for %q: %#v", token, s)
}
}
}
}
// Invalid slash shapes (spaces etc.) and a bare "/" must not surface skills.
func TestSkillSuggestionEdgeCases(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "has space", Description: "not invocable", Content: "x"})
if got := m.matchSkillSuggestions("/has"); len(got) != 0 {
t.Fatalf("invalid slash-shape skill must not be suggested, got %#v", got)
}
if got := m.matchSkillSuggestions("/"); got != nil {
t.Fatalf("bare '/' should not list skills, got %#v", got)
}
}
func TestSkillsCommandResolves(t *testing.T) {
command, ok := resolveCommand("/skills")
if !ok || command.kind != commandSkills {
t.Fatalf("resolveCommand(/skills) = %#v, %v", command, ok)
}
}
// With skills installed, /skills opens a searchable picker (like /model).
func TestSkillsCommandOpensPicker(t *testing.T) {
m := newSkillTestModel(t,
skills.Skill{Name: "deploy-checks", Description: "Run the pre-deploy suite.", Content: "x"},
skills.Skill{Name: "has space", Description: "tool-only skill", Content: "x"},
)
next := submitInput(t, m, "/skills")
if next.picker == nil || next.picker.kind != pickerSkill {
t.Fatalf("/skills should open the skill picker, got %#v", next.picker)
}
labels := map[string]string{}
for _, item := range next.picker.items {
labels[item.Label] = item.Value
}
if labels["/deploy-checks"] != "deploy-checks" {
t.Fatalf("slash-invocable skill should show its slash form, got %#v", labels)
}
// A non-slash-shaped name is still selectable (bare label, exact-name value).
if labels["has space"] != "has space" {
t.Fatalf("non-slash skill should be listed bare but selectable, got %#v", labels)
}
}
// Enter on a picker row fills the composer with "/name " so the user can add
// their request (which PR? which file?) before submitting — it must NOT fire
// the skill with no request attached.
func TestSkillPickerEnterFillsComposer(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff for correctness."})
next := submitInput(t, m, "/skills")
if next.picker == nil {
t.Fatal("picker should be open")
}
updated, _ := next.Update(testKey(tea.KeyEnter))
after := updated.(model)
if after.picker != nil {
t.Fatal("picker should close on Enter")
}
if got := after.input.Value(); got != "/reviewer " {
t.Fatalf("composer should be filled with the invocation, got %q", got)
}
if transcriptContains(after.transcript, "Review the diff for correctness.") {
t.Fatalf("skill must not run before the user submits, got %#v", after.transcript)
}
// A second Enter submits the (bare) invocation and runs the skill.
final, _ := after.Update(testKey(tea.KeyEnter))
done := final.(model)
if !transcriptContains(done.transcript, "Review the diff for correctness.") {
t.Fatalf("submitting the filled composer should run the skill, got %#v", done.transcript)
}
}
// Picker selection is by exact name, so a skill shadowed by a builtin (dead on
// the slash path) is still runnable from the picker.
func TestSkillPickerRunsBuiltinShadowedSkill(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "help", Description: "shadowed", Content: "Shadowed skill body."})
next := submitInput(t, m, "/skills")
if next.picker == nil {
t.Fatal("picker should be open")
}
updated, _ := next.Update(testKey(tea.KeyEnter))
after := updated.(model)
if !transcriptContains(after.transcript, "Shadowed skill body.") {
t.Fatalf("picker must run the shadowed skill directly, got %#v", after.transcript)
}
}
func TestSkillsCommandEmptyState(t *testing.T) {
m := newModel(context.Background(), Options{Cwd: t.TempDir()})
next := submitInput(t, m, "/skills")
if !transcriptContains(next.transcript, "No skills installed.") {
t.Fatalf("empty state should say no skills, got %#v", next.transcript)
}
}
// Run-state guards: an expanded skill prompt must not race an active run — it
// queues (as the EXPANDED prompt, since the queue flush submits literal text).
func TestSkillInvocationQueuedWhileRunPending(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review the diff."})
m.pending = true
next, _, handled := m.handleSkillCommand("/reviewer focus on tests")
if !handled {
t.Fatal("skill must be handled even while a run is pending")
}
if next.queuedMessage == "" || !strings.Contains(next.queuedMessage, "Review the diff.") {
t.Fatalf("expanded prompt should be queued, got %q", next.queuedMessage)
}
if !strings.Contains(next.queuedMessage, "focus on tests") {
t.Fatalf("queued prompt should carry the args, got %q", next.queuedMessage)
}
}
func TestSkillInvocationDroppedWhileExiting(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
m.exiting = true
next, cmd, handled := m.handleSkillCommand("/reviewer")
if !handled || cmd != nil || next.queuedMessage != "" {
t.Fatalf("exiting must swallow the invocation: handled=%v cmd=%v queued=%q", handled, cmd, next.queuedMessage)
}
}
func TestSkillInvocationWarnsDuringCompaction(t *testing.T) {
m := newSkillTestModel(t, skills.Skill{Name: "reviewer", Content: "Review."})
m.compactInFlight = true
next, _, handled := m.handleSkillCommand("/reviewer")
if !handled {
t.Fatal("skill must be handled during compaction")
}
if !transcriptContains(next.transcript, "Compaction is running") {
t.Fatalf("compaction warning expected, got %#v", next.transcript)
}
// The typed invocation is restored for an easy re-submit (the dispatch path
// cleared the composer before the guard could fire).
if got := next.input.Value(); got != "/reviewer" {
t.Fatalf("typed invocation should be restored into the composer, got %q", got)
}
}
// The same guard now protects user commands (previously they could start a
// second concurrent run).
func TestUserCommandQueuedWhileRunPending(t *testing.T) {
root := t.TempDir()
writeUserCommand(t, root, "greet.md", "Say hello to $1.")
m := newModel(context.Background(), Options{Cwd: root})
m.pending = true
next, _, handled := m.handleUserCommand("/greet world")
if !handled {
t.Fatal("user command must be handled while pending")
}
if !strings.Contains(next.queuedMessage, "Say hello to world.") {
t.Fatalf("expanded user-command prompt should be queued, got %q", next.queuedMessage)
}
}
// Two skills whose names collide after lowercasing map to one slash name; only
// the one dispatch will run may be advertised.
func TestCaseCollidingSkillsAdvertisedOnce(t *testing.T) {
m := newSkillTestModel(t,
skills.Skill{Name: "Deploy", Description: "wins", Content: "a"},
skills.Skill{Name: "deploy", Description: "loses", Content: "b"},
)
rows := 0
for _, s := range m.matchSkillSuggestions("/dep") {
if s.Name == "/deploy" {
rows++
if !strings.Contains(s.Desc, "wins") {
t.Fatalf("advertised row must describe the skill dispatch runs, got %q", s.Desc)
}
}
}
if rows != 1 {
t.Fatalf("case-colliding skills must yield exactly one /deploy row, got %d", rows)
}
}
// The palette reads through the loader, so a skill installed mid-session (the
// loader's next result) appears without a restart.
func TestSkillPaletteReadsThroughLoader(t *testing.T) {
installed := []skills.Skill{}
m := newModel(context.Background(), Options{
Cwd: t.TempDir(),
LoadSkills: func() []skills.Skill { return installed },
})
if got := m.matchSkillSuggestions("/lat"); len(got) != 0 {
t.Fatalf("no skills installed yet, got %#v", got)
}
installed = append(installed, skills.Skill{Name: "late-arrival", Description: "installed mid-session", Content: "x"})
got := m.matchSkillSuggestions("/lat")
if len(got) != 1 || got[0].Name != "/late-arrival" {
t.Fatalf("mid-session skill should appear via the loader, got %#v", got)
}
}
@@ -22,7 +22,7 @@ func TestTranscriptBodyItemsIncludesSummaryBeforeCards(t *testing.T) {
specialistInfo: &specialistInfo{name: "explorer", description: "map code", childSessionID: "s2", status: specialistCompleted, startedAt: now, completedAt: now, tokenCount: 2000},
})
items := m.transcriptBodyItems(80, "", false)
items := m.transcriptBodyItems(80, "")
body := bodyItemsString(m, items, 80)
if !strings.Contains(body, "specialists") {
t.Errorf("transcript body should contain summary line with 'specialists', got:\n%s", body)
+4 -4
View File
@@ -6,7 +6,7 @@ func TestTranscriptBodyItemsRepresentEmptyState(t *testing.T) {
m := mouseTestModel()
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "", false)
items := m.transcriptBodyItems(width, "")
layout := layoutTranscriptBodyItems(items)
if len(layout.spans) != 1 || layout.spans[0].kind != transcriptBodyItemEmpty {
@@ -25,7 +25,7 @@ func TestTranscriptBodyItemsShiftSelectableLinesByItemStart(t *testing.T) {
m.transcript = appendRow(m.transcript, rowUser, "hello")
width := m.chatColumnWidth()
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, "", false))
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, ""))
if len(layout.spans) != 1 || layout.spans[0].kind != transcriptBodyItemRow {
t.Fatalf("spans = %#v, want one transcript row item", layout.spans)
@@ -51,7 +51,7 @@ func TestTranscriptBodyItemsKeepPendingInterimSelectableLocal(t *testing.T) {
m.streamingReasoning = "private thought"
width := m.chatColumnWidth()
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, "", false))
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, ""))
if len(layout.spans) != 2 {
t.Fatalf("spans = %#v, want separator plus pending interim", layout.spans)
@@ -181,7 +181,7 @@ func TestScrollableTranscriptItemsViewMatchesFullLayout(t *testing.T) {
width := m.chatColumnWidth()
header := m.pinnedTitleBar(width)
footer := m.footerView(width)
items := m.transcriptBodyItems(width, "", false)
items := m.transcriptBodyItems(width, "")
full := layoutTranscriptBodyItems(items)
got := m.scrollableTranscriptItemsView(header, items, footer, width, "")
+28 -80
View File
@@ -107,7 +107,7 @@ type transcriptBodyLayout struct {
}
func (m model) transcriptBodyLayout(width int, emptyOverlay string) transcriptBodyLayout {
return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay, false))
return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay))
}
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
@@ -210,7 +210,7 @@ func (m model) renderHoverHighlight(rendered string, selectable []transcriptSele
return strings.Join(lines, "\n")
}
func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem {
func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem {
// File drill-in: the chat column's body swaps to the viewed file's
// diff/content. Swapping HERE (the single source every consumer reads) keeps
// the viewport, scroll engine, renderer, and mouse hit-tests consistent.
@@ -241,19 +241,9 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool
} else {
rc := buildRowContext(m.transcript)
shownAny := false
// The detailed view shows the full transcript from index 0, not
// the managed region after m.flushed.
startIdx := m.flushed
if detailed {
startIdx = 0
}
renderRowFn := transcriptRowDispatchFn(m.renderTranscriptRow)
if detailed {
renderRowFn = transcriptRowDispatchFn(m.renderTranscriptDetailedRow)
}
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc)
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
specialistSummaryEmitted := false
for index := startIdx; index < len(m.transcript); index++ {
for index := m.flushed; index < len(m.transcript); index++ {
row := m.transcript[index]
// A welcome row carries no Lime visual (the empty state replaced it)
// and a resolved tool call collapses into its result's card.
@@ -354,18 +344,16 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool
}
}
rowIndex, transcriptRow := index, row
bodyCap := cardBodyMaxLines
if detailed {
bodyCap = 0
}
heightCacheKey, heightCacheStable := m.transcriptRowBodyHeightCacheKeyOpts(transcriptRow, contentWidth, rc, bodyCap)
// Key the height cache on contentWidth (the wrap width drives line count);
// the gutter is a horizontal-only post-pad and never changes height.
heightCacheKey, heightCacheStable := m.transcriptRowBodyHeightCacheKey(transcriptRow, contentWidth, rc)
items = append(items, transcriptBodyItem{
kind: transcriptBodyItemRow,
rowIndex: rowIndex,
heightCacheKey: heightCacheKey,
heightCacheStable: heightCacheStable,
render: func(startBodyY int) transcriptBodyRenderedItem {
rendered, selectable := renderRowFn(rowIndex, transcriptRow, contentWidth, rc, startBodyY)
rendered, selectable := m.renderTranscriptRow(rowIndex, transcriptRow, contentWidth, rc, startBodyY)
return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY)
},
})
@@ -647,17 +635,8 @@ func transcriptBlockBodyHeightCacheKey(kind transcriptBodyItemKind, block string
return b.String()
}
// rowRenderFn abstracts row rendering for normal vs detailed mode:
// m.renderRow for normal (bodyCap: cardBodyMaxLines) and m.renderRowDetailed
// for detailed (bodyCap: 0).
type rowRenderFn func(transcriptRow, int, rowContext) string
// transcriptRowDispatchFn dispatches row-kind rendering for the transcript body
// pipeline, producing rendered text and selectable-line geometry.
type transcriptRowDispatchFn func(int, transcriptRow, int, rowContext, int) (string, []transcriptSelectableLine)
func (m model) transcriptRowBodyHeightCacheKeyOpts(row transcriptRow, width int, rc rowContext, bodyCap int) (string, bool) {
opts := cardRenderOptions{bodyCap: bodyCap, cwd: m.cwd}
func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc rowContext) (string, bool) {
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
rowKey, stable := m.renderRowCacheKey(row, width, rc, opts, false)
if rowKey == "" {
return "", false
@@ -669,17 +648,6 @@ func (m model) transcriptRowBodyHeightCacheKeyOpts(row transcriptRow, width int,
}
func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
}
// renderTranscriptDetailedRow routes through renderTranscriptRowFn with
// renderRowDetailed (bodyCap: 0) so tool output appears uncapped.
func (m model) renderTranscriptDetailedRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRowDetailed)
}
// renderTranscriptRowFn dispatches row-kind rendering using the provided renderFn.
func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
switch row.kind {
case rowUser:
return m.renderSelectableUserRow(rowIndex, row, width, startBodyY)
@@ -688,26 +656,21 @@ func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int,
case rowReasoning:
return m.renderSelectableReasoningRow(rowIndex, row, width, startBodyY)
case rowSystem, rowError, rowToolCall, rowPermission, rowAskUser:
return m.renderSelectableRenderedRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
return m.renderSelectableRenderedRow(rowIndex, row, width, rc, startBodyY)
case rowToolResult:
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
return m.renderSelectableToolResultRow(rowIndex, row, width, rc, startBodyY)
case rowSpecialist:
return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
return m.renderSelectableSpecialistRow(rowIndex, row, width, rc, startBodyY)
default:
rendered := renderFn(row, width, rc)
if rendered == "" {
return "", nil
}
selectable := selectableLinesFromRendered(rowIndex, rendered, startBodyY, 0)
return rendered, selectable
return m.renderRow(row, width, rc), nil
}
}
// renderSelectableToolResultRow renders the tool result card and marks its head
// (first line) as a clickable collapse/expand toggle. Body/footer text remains
// selectable so copying a visible transcript range includes command output.
func (m model) renderSelectableToolResultRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
rendered := renderFn(row, width, rc)
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
rendered := m.renderRow(row, width, rc)
if rendered == "" {
return "", nil
}
@@ -731,12 +694,8 @@ func (m model) renderSelectableToolResultRowFn(rowIndex int, row transcriptRow,
return rendered, selectable
}
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
}
func (m model) renderSelectableRenderedRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
rendered := renderFn(row, width, rc)
func (m model) renderSelectableRenderedRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
rendered := m.renderRow(row, width, rc)
if rendered == "" {
return "", nil
}
@@ -869,8 +828,11 @@ func (m model) renderTranscriptSelectableStyledLine(line transcriptSelectableLin
return prefix + zeroTheme.selection.Render(selected) + suffix
}
func (m model) renderSelectableSpecialistRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
rendered := renderFn(row, width, rc)
// renderSelectableSpecialistRow renders a specialist card and marks every line
// as a clickable specialistCard selectable line carrying the childSessionID.
// A left-click or Enter on any card line drills into that specialist's subchat.
func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
rendered := m.renderRow(row, width, rc)
if rendered == "" || row.specialistInfo == nil {
return "", nil
}
@@ -884,6 +846,9 @@ func (m model) renderSelectableSpecialistRowFn(rowIndex int, row transcriptRow,
specialistCard: true,
specialistID: row.specialistInfo.childSessionID,
}
// Carry the line's plain text + start so a selection dragged THROUGH the
// card copies its content; the specialistCard flag still makes a direct
// click drill into the sub-session (handled on press, before selection).
if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY+i, lines[i], boxed); ok {
sl.text = meta.text
sl.textStart = meta.textStart
@@ -893,13 +858,6 @@ func (m model) renderSelectableSpecialistRowFn(rowIndex int, row transcriptRow,
return rendered, selectable
}
// renderSelectableSpecialistRow renders a specialist card and marks every line
// as a clickable specialistCard selectable line carrying the childSessionID.
// A left-click or Enter on any card line drills into that specialist's subchat.
func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
}
func (m model) renderSelectableUserRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) {
contentWidth := userPromptContentWidth(width)
wrapped := wrapPlainText(row.text, maxInt(1, contentWidth))
@@ -1072,18 +1030,12 @@ func splitPlainAtDisplayWidth(text string, width int) (string, string) {
// selection previously resolved against transcript rows that weren't even
// visible while viewing a subagent/swarm child session.
func (m model) transcriptHitTestSource() (header string, items []transcriptBodyItem, width int) {
if m.transcriptDetailed {
width = chatWidth(m.width)
header = detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
items = m.transcriptBodyItems(width, "", true)
return
}
if m.subchat.active {
width = chatWidth(m.width)
return renderSubchatNavBar(m.subchat.childSessionTitle, width), m.transcriptBodyItemsFromRows(m.subchat.childRows, width), width
}
width = m.chatColumnWidth()
return m.pinnedTitleBar(width), m.transcriptBodyItems(width, "", false), width
return m.pinnedTitleBar(width), m.transcriptBodyItems(width, ""), width
}
// transcriptHitTestBlocked reports whether mouse hit-testing must be skipped
@@ -1097,11 +1049,7 @@ func (m model) transcriptHitTestBlocked() bool {
// (nearest-line fallback for scroll-driven selection extension).
func (m model) transcriptHitTestLayout() (frame transcriptFrameLayout, window transcriptViewportWindow, layout transcriptBodyLayout) {
header, items, width := m.transcriptHitTestSource()
footer := m.footerView(width)
if m.transcriptDetailed {
footer = m.detailedTranscriptFooter(width)
}
frame = m.scrollableTranscriptFrame(header, footer)
frame = m.scrollableTranscriptFrame(header, m.footerView(width))
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
window = transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
layout = layoutVisibleTranscriptBodyItems(items, metrics, window)
+43 -30
View File
@@ -1,16 +1,10 @@
package tui
import (
"fmt"
"strings"
)
import "strings"
func (m model) toggleDetailedTranscript() model {
m.transcriptDetailed = !m.transcriptDetailed
m.clearSuggestions()
if m.fileView.active {
m = m.exitFileView()
}
if m.picker != nil && m.picker.kind == pickerTheme {
// Dropping a theme picker mid-preview must undo the live palette, or the
// previewed theme would stick while m.themeMode still holds the old one.
@@ -20,36 +14,55 @@ func (m model) toggleDetailedTranscript() model {
return m
}
// detailedTranscriptView renders the full (uncapped) transcript viewport.
// Tool output that would be truncated in the live view appears in full here.
func (m model) detailedTranscriptView() string {
width := chatWidth(m.width)
items := m.transcriptBodyItems(width, "", true)
rc := buildRowContext(m.transcript)
renderer := m
renderer.pending = false
renderer.activeRunID = 0
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
footer := m.detailedTranscriptFooter(width)
var builder strings.Builder
builder.WriteString(detailedTranscriptHeader(width))
builder.WriteString("\n")
builder.WriteString(zeroTheme.line.Render(strings.Repeat("-", width)))
builder.WriteString("\n")
if m.altScreen && m.height > 0 {
return m.scrollableTranscriptItemsView(header, items, footer, width, "")
}
// Popup/fallback: render the full body without viewport clipping.
layout := layoutTranscriptBodyItems(items)
return header + "\n" + layout.String() + "\n" + footer
}
// detailedTranscriptFooter builds the one-line footer for the detailed transcript
// view: copy status when active, otherwise the key hint with jump-to-bottom cue.
func (m model) detailedTranscriptFooter(width int) string {
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
return rightAlignedLine(zeroTheme.ink.Render(copyStatus), width)
shownAny := false
var previousKind rowKind
for _, row := range m.transcript {
if rc.skip(row) {
continue
}
rendered := ""
if row.kind == rowWelcome {
rendered = fitStyledLine(zeroTheme.faint.Render(row.text), width)
} else {
rendered = renderer.renderRowDetailed(row, width, rc)
}
if rendered == "" {
continue
}
if shownAny && startsTurn(row.kind) {
builder.WriteString("\n")
}
if shownAny && previousKind == rowUser && row.kind == rowReasoning {
builder.WriteString("\n")
}
builder.WriteString(rendered)
builder.WriteString("\n")
shownAny = true
previousKind = row.kind
}
detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O")
hint := zeroTheme.faint.Render(fmt.Sprintf("Esc close | %s toggle", detailKey))
if jt := m.jumpToBottomHint(); jt != "" {
return fitStyledLine(joinHeaderLine(hint, jt, width), width)
if !shownAny {
builder.WriteString(zeroTheme.faint.Render("No transcript rows."))
builder.WriteString("\n")
}
return fitStyledLine(hint, width)
builder.WriteString(zeroTheme.line.Render(strings.Repeat("-", width)))
builder.WriteString("\n")
builder.WriteString(fitStyledLine(zeroTheme.faint.Render("Esc close | Ctrl+O toggle"), width))
return builder.String()
}
func detailedTranscriptHeader(width int) string {
-72
View File
@@ -124,78 +124,6 @@ func transcriptViewTestModel() model {
return m
}
func TestPgUpDownScrollsDetailedTranscript(t *testing.T) {
m := transcriptViewTestModel()
m.altScreen = true
// Fill the transcript enough to overflow the 30-row viewport.
for i := 0; i < 60; i++ {
m.transcript = append(m.transcript, transcriptRow{kind: rowUser, text: "line content", final: true})
}
m.flushed = len(m.transcript)
// Enter detailed mode.
updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)
if !m.transcriptDetailed {
t.Fatal("sanity check failed: Ctrl+O should enter detailed mode")
}
if m.chatScrollOffset != 0 {
t.Fatal("sanity check failed: detailed transcript should start at bottom")
}
// PgUp reveals older content = positive scroll offset.
updated, _ = m.Update(testKey(tea.KeyPgUp))
m = updated.(model)
if m.chatScrollOffset <= 0 {
t.Fatalf("PgUp in detailed mode should scroll toward older content, got chatScrollOffset=%d", m.chatScrollOffset)
}
// PgDown scrolls back toward bottom.
updated, _ = m.Update(testKey(tea.KeyPgDown))
m = updated.(model)
if m.chatScrollOffset != 0 {
t.Fatalf("PgDown after PgUp should return to bottom in detailed mode, got chatScrollOffset=%d", m.chatScrollOffset)
}
}
func TestUpDownArrowsScrollDetailedTranscript(t *testing.T) {
m := transcriptViewTestModel()
m.altScreen = true
for i := 0; i < 60; i++ {
m.transcript = append(m.transcript, transcriptRow{kind: rowUser, text: "line content", final: true})
}
m.flushed = len(m.transcript)
updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)
if !m.transcriptDetailed {
t.Fatal("sanity check failed: Ctrl+O should enter detailed mode")
}
// Arrow down scrolls one line toward newer content; at bottom it stays at 0.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
if m.chatScrollOffset != 0 {
t.Fatalf("KeyDown at bottom should stay at 0, got %d", m.chatScrollOffset)
}
// Arrow up scrolls one line toward older content.
updated, _ = m.Update(testKey(tea.KeyUp))
m = updated.(model)
if m.chatScrollOffset != 1 {
t.Fatalf("KeyUp should scroll one line up, got chatScrollOffset=%d", m.chatScrollOffset)
}
// Arrow down scrolls back toward bottom.
updated, _ = m.Update(testKey(tea.KeyDown))
m = updated.(model)
if m.chatScrollOffset != 0 {
t.Fatalf("KeyDown after KeyUp should return to bottom, got chatScrollOffset=%d", m.chatScrollOffset)
}
}
func numberedLines(count int) string {
lines := make([]string, 0, count)
for i := 1; i <= count; i++ {
+2 -3
View File
@@ -30,9 +30,8 @@ func (m model) handleUserCommand(raw string) (model, tea.Cmd, bool) {
if strings.TrimSpace(prompt) == "" {
return m, nil, false
}
// Same run-state guards as a plain prompt: a user command invoked mid-run is
// queued (as its expanded prompt), not raced into a second concurrent turn.
return m.launchOrDeferExpandedPrompt(raw, prompt)
next, teaCmd := m.launchPrompt(prompt)
return next, teaCmd, true
}
// lookupUserCommand returns the loaded user command with the given (lowercased)
-156
View File
@@ -526,159 +526,3 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
type PushOptions struct {
Cwd string
Remote string
Branch string
Force bool
DryRun bool
AllowPushDefaultBranch bool
RunGit Runner
RunGitEnv EnvRunner
}
type PushResult struct {
Remote string `json:"remote"`
Branch string `json:"branch"`
Output string `json:"output"`
}
func Push(ctx context.Context, options PushOptions) (PushResult, error) {
cwd, err := resolveCwd(options.Cwd)
if err != nil {
return PushResult{}, err
}
runGit, _ := resolveRunners(options.RunGit, options.RunGitEnv)
root, err := gitOutput(ctx, runGit, cwd, "rev-parse", "--show-toplevel")
if err != nil {
return PushResult{}, fmt.Errorf("not a git repository: %w", err)
}
root = filepath.Clean(root)
branch := strings.TrimSpace(options.Branch)
if branch == "" {
branch, err = gitOutput(ctx, runGit, root, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return PushResult{}, fmt.Errorf("resolve current branch: %w", err)
}
}
if branch == "HEAD" {
return PushResult{}, fmt.Errorf("cannot push: not currently on a branch")
}
remote := strings.TrimSpace(options.Remote)
if remote == "" {
if upstream, err := gitOutput(ctx, runGit, root, "config", "branch."+branch+".remote"); err == nil && upstream != "" {
remote = upstream
} else {
remote = "origin"
}
}
if !options.AllowPushDefaultBranch {
if isDefaultBranch(ctx, runGit, root, remote, branch) {
return PushResult{}, fmt.Errorf("refusing to push to %q (default/protected branch); use --yes to override", branch)
}
}
args := []string{"push"}
if options.DryRun {
args = append(args, "--dry-run")
}
if options.Force {
args = append(args, "--force-with-lease")
}
args = append(args, "-u", "--", remote, branch)
output, err := gitRawOutput(ctx, runGit, root, args...)
if err != nil {
return PushResult{}, fmt.Errorf("push: %w", err)
}
return PushResult{
Remote: remote,
Branch: branch,
Output: output,
}, nil
}
func isDefaultBranch(ctx context.Context, runGit Runner, dir, remote, branch string) bool {
if out, err := gitOutput(ctx, runGit, dir, "ls-remote", "--symref", remote, "HEAD"); err == nil {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "ref: refs/heads/") && strings.HasSuffix(line, "\tHEAD") {
symref := strings.TrimPrefix(line, "ref: refs/heads/")
symref = strings.TrimSuffix(symref, "\tHEAD")
return branch == symref
}
}
}
return branch == "main" || branch == "master"
}
type PROptions struct {
Cwd string
Fill bool
Draft bool
Title string
Body string
RunGH Runner
}
type PRResult struct {
Output string `json:"output"`
}
func CreatePR(ctx context.Context, options PROptions) (PRResult, error) {
cwd, err := resolveCwd(options.Cwd)
if err != nil {
return PRResult{}, err
}
runGH := options.RunGH
if runGH == nil {
runGH = func(ctx context.Context, dir string, args ...string) (CommandResult, error) {
cmd := exec.CommandContext(ctx, "gh", args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
var exitCode int
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
} else {
exitCode = -1
}
}
return CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitCode}, err
}
}
prArgs := []string{"pr", "create"}
if options.Fill {
prArgs = append(prArgs, "--fill")
}
if options.Draft {
prArgs = append(prArgs, "--draft")
}
if options.Title != "" {
prArgs = append(prArgs, "--title", options.Title)
}
if options.Body != "" {
prArgs = append(prArgs, "--body", options.Body)
}
res, err := runGH(ctx, cwd, prArgs...)
if err != nil {
return PRResult{}, fmt.Errorf("gh pr create failed: %w\n%s", err, res.Stderr)
}
return PRResult{
Output: res.Stdout,
}, nil
}
-206
View File
@@ -5,7 +5,6 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -522,208 +521,3 @@ func TestParseStatusZHandlesRenamesAndSpecialPaths(t *testing.T) {
}
}
}
func TestPushBranchesToRemote(t *testing.T) {
t.Run("HappyPath", func(t *testing.T) {
root := t.TempDir()
runner := &fakeRunner{results: []CommandResult{
{Stdout: root + "\n"},
{Stdout: "feat/some-feature\n"},
{Stdout: "origin\n"}, // config branch.feat/some-feature.remote
{}, // ls-remote --symref (no match → falls through)
{Stdout: "Everything up-to-date\n"},
}}
result, err := Push(context.Background(), PushOptions{
Cwd: root,
RunGit: runner.Run,
})
if err != nil {
t.Fatalf("Push returned error: %v", err)
}
if result.Remote != "origin" || result.Branch != "feat/some-feature" || !strings.Contains(result.Output, "Everything up-to-date") {
t.Fatalf("unexpected push result: %#v", result)
}
if got := runner.commandLine(4); got != "git push -u -- origin feat/some-feature" {
t.Fatalf("unexpected push command: %q", got)
}
})
t.Run("FlagsForceAndDryRun", func(t *testing.T) {
root := t.TempDir()
runner := &fakeRunner{results: []CommandResult{
{Stdout: root + "\n"},
{Stdout: "feat/some-feature\n"},
{Stdout: "origin\n"}, // config branch.feat/some-feature.remote
{}, // ls-remote --symref (no match)
{Stdout: "Everything up-to-date\n"},
}}
_, err := Push(context.Background(), PushOptions{
Cwd: root,
RunGit: runner.Run,
Force: true,
DryRun: true,
})
if err != nil {
t.Fatalf("Push returned error: %v", err)
}
if got := runner.commandLine(4); got != "git push --dry-run --force-with-lease -u -- origin feat/some-feature" {
t.Fatalf("unexpected push command: %q", got)
}
})
t.Run("DetachedHEAD", func(t *testing.T) {
root := t.TempDir()
runner := &fakeRunner{results: []CommandResult{
{Stdout: root + "\n"},
{Stdout: "HEAD\n"},
}}
_, err := Push(context.Background(), PushOptions{
Cwd: root,
RunGit: runner.Run,
})
if err == nil {
t.Fatal("expected error on detached HEAD push, got nil")
}
if !strings.Contains(err.Error(), "cannot push: not currently on a branch") {
t.Fatalf("unexpected error message: %v", err)
}
})
t.Run("RejectsDefaultBranch", func(t *testing.T) {
for _, branch := range []string{"main", "master"} {
root := t.TempDir()
runner := &fakeRunner{results: []CommandResult{
{Stdout: root + "\n"},
{Stdout: branch + "\n"},
}}
_, err := Push(context.Background(), PushOptions{
Cwd: root,
RunGit: runner.Run,
})
if err == nil {
t.Fatalf("expected error when pushing %q, got nil", branch)
}
if !strings.Contains(err.Error(), "default/protected branch") {
t.Fatalf("unexpected error for %q: %v", branch, err)
}
}
})
t.Run("AllowDefaultBranchWithYes", func(t *testing.T) {
root := t.TempDir()
runner := &fakeRunner{results: []CommandResult{
{Stdout: root + "\n"},
{Stdout: "main\n"},
{Stdout: "origin\n"},
{Stdout: "Everything up-to-date\n"},
}}
result, err := Push(context.Background(), PushOptions{
Cwd: root,
RunGit: runner.Run,
AllowPushDefaultBranch: true,
})
if err != nil {
t.Fatalf("Push returned error: %v", err)
}
if result.Branch != "main" {
t.Fatalf("expected branch main, got %q", result.Branch)
}
})
t.Run("FallbackRemoteToOrigin", func(t *testing.T) {
root := t.TempDir()
runner := &fakeRunner{results: []CommandResult{
{Stdout: root + "\n"},
{Stdout: "feat/some-feature\n"},
{ExitCode: 1, Stderr: "error: no such section"}, // config lookup fails
{}, // ls-remote --symref (no match)
{Stdout: "Everything up-to-date\n"},
}}
result, err := Push(context.Background(), PushOptions{
Cwd: root,
RunGit: runner.Run,
})
if err != nil {
t.Fatalf("Push returned error: %v", err)
}
if result.Remote != "origin" {
t.Fatalf("expected fallback remote to be origin, got: %q", result.Remote)
}
if got := runner.commandLine(4); got != "git push -u -- origin feat/some-feature" {
t.Fatalf("unexpected push command: %q", got)
}
})
}
func TestCreatePRCommandConstruction(t *testing.T) {
t.Run("CreatePRWithAllOptions", func(t *testing.T) {
root := t.TempDir()
runner := &fakeRunner{results: []CommandResult{
{Stdout: "https://github.com/Gitlawb/zero/pull/123\n"},
}}
result, err := CreatePR(context.Background(), PROptions{
Cwd: root,
Fill: true,
Draft: true,
Title: "Feat: some title",
Body: "Some body description",
RunGH: runner.Run,
})
if err != nil {
t.Fatalf("CreatePR returned error: %v", err)
}
if result.Output != "https://github.com/Gitlawb/zero/pull/123\n" {
t.Fatalf("unexpected PR result: %#v", result)
}
expectedArgs := []string{"pr", "create", "--fill", "--draft", "--title", "Feat: some title", "--body", "Some body description"}
if len(runner.calls) != 1 {
t.Fatalf("expected 1 runner call, got %d", len(runner.calls))
}
if got := runner.calls[0].args; !reflect.DeepEqual(got, expectedArgs) {
t.Fatalf("unexpected gh args: %v, want %v", got, expectedArgs)
}
if runner.calls[0].dir != root {
t.Fatalf("unexpected dir: %q, want %q", runner.calls[0].dir, root)
}
})
t.Run("CreatePRMinimal", func(t *testing.T) {
root := t.TempDir()
runner := &fakeRunner{results: []CommandResult{
{Stdout: "https://github.com/Gitlawb/zero/pull/124\n"},
}}
_, err := CreatePR(context.Background(), PROptions{
Cwd: root,
RunGH: runner.Run,
})
if err != nil {
t.Fatalf("CreatePR returned error: %v", err)
}
expectedArgs := []string{"pr", "create"}
if len(runner.calls) != 1 {
t.Fatalf("expected 1 runner call, got %d", len(runner.calls))
}
if got := runner.calls[0].args; !reflect.DeepEqual(got, expectedArgs) {
t.Fatalf("unexpected gh args: %v, want %v", got, expectedArgs)
}
if runner.calls[0].dir != root {
t.Fatalf("unexpected dir: %q, want %q", runner.calls[0].dir, root)
}
})
}
-6
View File
@@ -21,9 +21,6 @@ type CollectedStream struct {
// thinking blocks) that must be replayed on the next turn. Empty for providers
// or runs without extended thinking.
ReasoningBlocks []ReasoningBlock
// HasReasoning records whether the provider streamed reasoning deltas. The
// deltas remain non-answer content, but they still prove the turn was live.
HasReasoning bool
}
// Truncated reports whether the response ended for a non-normal reason (the
@@ -135,9 +132,6 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
options.OnText(event.Content)
}
case StreamEventReasoning:
if strings.TrimSpace(event.Content) != "" {
collected.HasReasoning = true
}
if options.OnReasoning != nil {
options.OnReasoning(event.Content)
}
-3
View File
@@ -312,9 +312,6 @@ func TestCollectStreamWithOptionsEmitsTextReasoningAndUsageCallbacks(t *testing.
if collected.Text != "Hello zero" {
t.Fatalf("text = %q, want Hello zero", collected.Text)
}
if !collected.HasReasoning {
t.Fatal("expected reasoning stream to mark collected turn as reasoning-bearing")
}
if len(textDeltas) != 2 || textDeltas[0] != "Hello " || textDeltas[1] != "zero" {
t.Fatalf("unexpected text callbacks: %#v", textDeltas)
}
-7
View File
@@ -203,13 +203,6 @@ type CompletionRequest struct {
// Anthropic/Gemini thinking budgets) and ignores it for models that do not
// support reasoning. Empty means "let the provider decide".
ReasoningEffort string
// PromptCacheKey, when non-empty, is an opaque stable identifier for the
// conversation (the session ID). Providers with server-side prefix-cache
// routing forward it — OpenAI `prompt_cache_key` — so consecutive requests
// land on a replica that already holds the cached prompt prefix instead of
// re-billing the full prefix each turn. Providers without an equivalent
// ignore it.
PromptCacheKey string
}
// Provider streams normalized completion events for one request.