mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c427052f9b | ||
|
|
fce9569362 | ||
|
|
3259ea3f6a | ||
|
|
f6e298ad20 | ||
|
|
15ddc79e75 | ||
|
|
0f69d995e9 | ||
|
|
87e7e69afd | ||
|
|
f4c998ac30 | ||
|
|
30bff28efc | ||
|
|
7b10aab74b |
No files matched your search
@@ -37,8 +37,13 @@ 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
|
||||
@@ -47,6 +47,12 @@ 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:
|
||||
@@ -307,6 +313,7 @@ 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.
|
||||
|
||||
@@ -173,6 +173,61 @@ 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`:
|
||||
|
||||
@@ -173,6 +173,7 @@ 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
|
||||
@@ -208,6 +209,7 @@ 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
|
||||
@@ -268,6 +270,7 @@ 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 {
|
||||
@@ -326,6 +329,7 @@ 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 {
|
||||
@@ -705,6 +709,7 @@ 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, ""
|
||||
|
||||
@@ -7,14 +7,18 @@ import (
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
// 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++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
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 !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
@@ -25,6 +29,27 @@ 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)
|
||||
|
||||
+126
-26
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
@@ -38,6 +39,14 @@ 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
|
||||
@@ -81,6 +90,13 @@ 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)
|
||||
}
|
||||
@@ -175,10 +191,19 @@ 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 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
|
||||
// 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
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
@@ -186,7 +211,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. 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")
|
||||
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")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -216,9 +241,11 @@ 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.
|
||||
// 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.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 100
|
||||
const maxDescRunes = 200
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
@@ -312,13 +339,7 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
content = truncateGuidelineContent(content, limit)
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
@@ -326,6 +347,92 @@ 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].
|
||||
@@ -378,23 +485,16 @@ 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 lowercased
|
||||
// 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 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
|
||||
}
|
||||
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())
|
||||
}
|
||||
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -63,6 +63,10 @@ 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.
|
||||
|
||||
|
||||
@@ -9,6 +9,25 @@ 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{}))
|
||||
|
||||
@@ -128,6 +147,88 @@ 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.
|
||||
@@ -257,6 +358,30 @@ 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")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -782,6 +783,14 @@ 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,
|
||||
@@ -1277,3 +1286,26 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -421,6 +422,12 @@ 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 {
|
||||
@@ -437,6 +444,15 @@ 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.
|
||||
|
||||
@@ -1259,3 +1259,61 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ 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
|
||||
|
||||
@@ -3,8 +3,9 @@ package sandbox
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errWindowsSandboxNotInitialized is returned only to a caller that explicitly
|
||||
@@ -121,9 +122,55 @@ 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 = exec.LookPath
|
||||
lookup = lookupExecutable
|
||||
}
|
||||
switch goos {
|
||||
case "linux":
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -237,8 +238,10 @@ func renderAssistantMarkdownPlainText(text string, proseMeasure int, tableMeasur
|
||||
}
|
||||
|
||||
func stripMarkdownRenderControls(text string) string {
|
||||
text = strings.ReplaceAll(text, markdownBoldStart, "")
|
||||
return strings.ReplaceAll(text, markdownBoldEnd, "")
|
||||
// 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)
|
||||
}
|
||||
|
||||
func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
|
||||
@@ -188,7 +188,54 @@ func (m model) matchCommandSuggestions(token string) []commandSuggestion {
|
||||
out := matchCommandSuggestionsWithFilter(token, func(command commandDefinition) bool {
|
||||
return command.kind != commandSandboxSetup || m.sandboxSetupCommand != nil
|
||||
})
|
||||
return append(out, m.matchUserCommandSuggestions(token)...)
|
||||
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
|
||||
}
|
||||
|
||||
// matchUserCommandSuggestions returns file-sourced /commands whose name has the
|
||||
|
||||
@@ -698,3 +698,19 @@ 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`)",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,7 @@ const (
|
||||
commandCopy
|
||||
commandExport
|
||||
commandNew
|
||||
commandSkills
|
||||
commandUnknown
|
||||
)
|
||||
|
||||
@@ -131,6 +132,13 @@ 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",
|
||||
|
||||
@@ -280,7 +280,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
|
||||
}
|
||||
|
||||
func (m model) composerMouseSelectionBlocked() bool {
|
||||
return m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m = m.openFileView("internal/tui/sidebar.go")
|
||||
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
|
||||
}
|
||||
@@ -264,6 +264,55 @@ 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).
|
||||
|
||||
@@ -372,7 +372,7 @@ func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
startY := -1
|
||||
for _, span := range metrics.spans {
|
||||
|
||||
+167
-22
@@ -29,6 +29,7 @@ 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"
|
||||
@@ -62,6 +63,7 @@ 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
|
||||
@@ -735,6 +737,7 @@ 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,
|
||||
@@ -1354,7 +1357,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.noBlockingModal() && m.sidebarToggleAllowed() {
|
||||
if !m.transcriptDetailed && 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
|
||||
@@ -1421,22 +1424,50 @@ 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):
|
||||
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(-m.chatPageScrollLines()), nil
|
||||
case keyIs(msg, tea.KeyDown):
|
||||
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
|
||||
}
|
||||
@@ -1462,6 +1493,41 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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
|
||||
}
|
||||
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() {
|
||||
m.moveSuggestion(1)
|
||||
return m, nil
|
||||
@@ -1480,7 +1546,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(1), nil
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
return m.movePermissionCursor(-1), nil
|
||||
@@ -1514,6 +1581,48 @@ 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
|
||||
@@ -2215,14 +2324,23 @@ func (m model) View() tea.View {
|
||||
}
|
||||
view.ReportFocus = m.notifier != nil
|
||||
if m.wantsMouseCapture() {
|
||||
// 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
|
||||
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
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -2297,7 +2415,7 @@ func (m model) transcriptView() string {
|
||||
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
|
||||
emptyOverlay = viewportOverlay
|
||||
}
|
||||
bodyItems := m.transcriptBodyItems(width, emptyOverlay)
|
||||
bodyItems := m.transcriptBodyItems(width, emptyOverlay, false)
|
||||
|
||||
footer := m.footerView(width)
|
||||
|
||||
@@ -2337,7 +2455,7 @@ func (m model) twoColumnTranscriptView() string {
|
||||
width := chatW
|
||||
|
||||
suggestionOverlay := m.suggestionOverlay(width)
|
||||
bodyItems := m.transcriptBodyItems(width, "")
|
||||
bodyItems := m.transcriptBodyItems(width, "", false)
|
||||
footer := m.footerView(width)
|
||||
overlayForViewport := suggestionOverlay
|
||||
if m.transcriptEmpty() && !m.pending {
|
||||
@@ -2767,7 +2885,15 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
|
||||
return transcriptViewport{}, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
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)
|
||||
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
|
||||
@@ -3607,6 +3733,11 @@ 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.
|
||||
@@ -3722,6 +3853,15 @@ 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
|
||||
@@ -3917,6 +4057,11 @@ 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,
|
||||
|
||||
@@ -2142,7 +2142,7 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
findRow := func(mm model) (transcriptBodyItem, bool) {
|
||||
for _, it := range mm.transcriptBodyItems(width, "") {
|
||||
for _, it := range mm.transcriptBodyItems(width, "", false) {
|
||||
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(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
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(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
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(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
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(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
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(), "")
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
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, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
var row *transcriptBodyItem
|
||||
for i := range items {
|
||||
if items[i].kind == transcriptBodyItemRow && items[i].rowIndex >= 0 {
|
||||
|
||||
+32
-2
@@ -1,6 +1,36 @@
|
||||
package tui
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
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)
|
||||
})
|
||||
|
||||
type mouseOverlayHit struct {
|
||||
x int
|
||||
@@ -257,7 +287,7 @@ func (m model) syncMouseCapture() (model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
|
||||
if !m.altScreen || m.height <= 0 {
|
||||
if !m.altScreen || m.height <= 0 || m.transcriptDetailed {
|
||||
return false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
@@ -920,6 +920,30 @@ 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)
|
||||
@@ -986,6 +1010,38 @@ 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{
|
||||
|
||||
@@ -13,6 +13,7 @@ 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"
|
||||
@@ -48,7 +49,13 @@ type Options struct {
|
||||
SessionCompactor SessionCompactor
|
||||
PrService *PrService
|
||||
|
||||
AgentOptions agent.Options
|
||||
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
|
||||
PermissionMode agent.PermissionMode
|
||||
ReasoningEffort modelregistry.ReasoningEffort
|
||||
ResponseStyle string
|
||||
|
||||
@@ -309,3 +309,70 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
pickerEffort
|
||||
pickerSession
|
||||
pickerTheme
|
||||
pickerSkill
|
||||
)
|
||||
|
||||
// pickerItem is one selectable row: Label is shown, Value is passed to the
|
||||
@@ -225,6 +226,35 @@ 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 {
|
||||
|
||||
@@ -511,6 +511,50 @@ 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{
|
||||
|
||||
@@ -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 {
|
||||
if !m.altScreen || m.height <= 0 || m.subchat.active || m.transcriptDetailed {
|
||||
return false
|
||||
}
|
||||
if sidebarWidth(m.width) <= 0 {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
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, "")
|
||||
items := m.transcriptBodyItems(80, "", false)
|
||||
body := bodyItemsString(m, items, 80)
|
||||
if !strings.Contains(body, "specialists") {
|
||||
t.Errorf("transcript body should contain summary line with 'specialists', got:\n%s", body)
|
||||
|
||||
@@ -6,7 +6,7 @@ func TestTranscriptBodyItemsRepresentEmptyState(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
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, ""))
|
||||
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, "", false))
|
||||
|
||||
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, ""))
|
||||
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, "", false))
|
||||
|
||||
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, "")
|
||||
items := m.transcriptBodyItems(width, "", false)
|
||||
full := layoutTranscriptBodyItems(items)
|
||||
|
||||
got := m.scrollableTranscriptItemsView(header, items, footer, width, "")
|
||||
|
||||
@@ -107,7 +107,7 @@ type transcriptBodyLayout struct {
|
||||
}
|
||||
|
||||
func (m model) transcriptBodyLayout(width int, emptyOverlay string) transcriptBodyLayout {
|
||||
return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay))
|
||||
return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay, false))
|
||||
}
|
||||
|
||||
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) []transcriptBodyItem {
|
||||
func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool) []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,9 +241,19 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
} else {
|
||||
rc := buildRowContext(m.transcript)
|
||||
shownAny := false
|
||||
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
||||
// 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)
|
||||
specialistSummaryEmitted := false
|
||||
for index := m.flushed; index < len(m.transcript); index++ {
|
||||
for index := startIdx; 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.
|
||||
@@ -344,16 +354,18 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
}
|
||||
}
|
||||
rowIndex, transcriptRow := index, row
|
||||
// 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)
|
||||
bodyCap := cardBodyMaxLines
|
||||
if detailed {
|
||||
bodyCap = 0
|
||||
}
|
||||
heightCacheKey, heightCacheStable := m.transcriptRowBodyHeightCacheKeyOpts(transcriptRow, contentWidth, rc, bodyCap)
|
||||
items = append(items, transcriptBodyItem{
|
||||
kind: transcriptBodyItemRow,
|
||||
rowIndex: rowIndex,
|
||||
heightCacheKey: heightCacheKey,
|
||||
heightCacheStable: heightCacheStable,
|
||||
render: func(startBodyY int) transcriptBodyRenderedItem {
|
||||
rendered, selectable := m.renderTranscriptRow(rowIndex, transcriptRow, contentWidth, rc, startBodyY)
|
||||
rendered, selectable := renderRowFn(rowIndex, transcriptRow, contentWidth, rc, startBodyY)
|
||||
return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY)
|
||||
},
|
||||
})
|
||||
@@ -635,8 +647,17 @@ func transcriptBlockBodyHeightCacheKey(kind transcriptBodyItemKind, block string
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc rowContext) (string, bool) {
|
||||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||||
// 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}
|
||||
rowKey, stable := m.renderRowCacheKey(row, width, rc, opts, false)
|
||||
if rowKey == "" {
|
||||
return "", false
|
||||
@@ -648,6 +669,17 @@ func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -656,21 +688,26 @@ func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, r
|
||||
case rowReasoning:
|
||||
return m.renderSelectableReasoningRow(rowIndex, row, width, startBodyY)
|
||||
case rowSystem, rowError, rowToolCall, rowPermission, rowAskUser:
|
||||
return m.renderSelectableRenderedRow(rowIndex, row, width, rc, startBodyY)
|
||||
return m.renderSelectableRenderedRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
case rowToolResult:
|
||||
return m.renderSelectableToolResultRow(rowIndex, row, width, rc, startBodyY)
|
||||
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
case rowSpecialist:
|
||||
return m.renderSelectableSpecialistRow(rowIndex, row, width, rc, startBodyY)
|
||||
return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, renderFn)
|
||||
default:
|
||||
return m.renderRow(row, width, rc), nil
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" {
|
||||
return "", nil
|
||||
}
|
||||
selectable := selectableLinesFromRendered(rowIndex, rendered, startBodyY, 0)
|
||||
return rendered, selectable
|
||||
}
|
||||
}
|
||||
|
||||
// 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) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
rendered := m.renderRow(row, width, rc)
|
||||
func (m model) renderSelectableToolResultRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" {
|
||||
return "", nil
|
||||
}
|
||||
@@ -694,8 +731,12 @@ func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, wi
|
||||
return rendered, selectable
|
||||
}
|
||||
|
||||
func (m model) renderSelectableRenderedRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
rendered := m.renderRow(row, width, rc)
|
||||
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)
|
||||
if rendered == "" {
|
||||
return "", nil
|
||||
}
|
||||
@@ -828,11 +869,8 @@ func (m model) renderTranscriptSelectableStyledLine(line transcriptSelectableLin
|
||||
return prefix + zeroTheme.selection.Render(selected) + suffix
|
||||
}
|
||||
|
||||
// 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)
|
||||
func (m model) renderSelectableSpecialistRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) {
|
||||
rendered := renderFn(row, width, rc)
|
||||
if rendered == "" || row.specialistInfo == nil {
|
||||
return "", nil
|
||||
}
|
||||
@@ -846,9 +884,6 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
|
||||
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
|
||||
@@ -858,6 +893,13 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
|
||||
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))
|
||||
@@ -1030,12 +1072,18 @@ 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, ""), width
|
||||
return m.pinnedTitleBar(width), m.transcriptBodyItems(width, "", false), width
|
||||
}
|
||||
|
||||
// transcriptHitTestBlocked reports whether mouse hit-testing must be skipped
|
||||
@@ -1049,7 +1097,11 @@ 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()
|
||||
frame = m.scrollableTranscriptFrame(header, m.footerView(width))
|
||||
footer := m.footerView(width)
|
||||
if m.transcriptDetailed {
|
||||
footer = m.detailedTranscriptFooter(width)
|
||||
}
|
||||
frame = m.scrollableTranscriptFrame(header, footer)
|
||||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
window = transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
|
||||
layout = layoutVisibleTranscriptBodyItems(items, metrics, window)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package tui
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"fmt"
|
||||
"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.
|
||||
@@ -14,55 +20,36 @@ 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)
|
||||
rc := buildRowContext(m.transcript)
|
||||
renderer := m
|
||||
renderer.pending = false
|
||||
renderer.activeRunID = 0
|
||||
items := m.transcriptBodyItems(width, "", true)
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString(detailedTranscriptHeader(width))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(zeroTheme.line.Render(strings.Repeat("-", width)))
|
||||
builder.WriteString("\n")
|
||||
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
|
||||
footer := m.detailedTranscriptFooter(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
|
||||
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)
|
||||
}
|
||||
|
||||
if !shownAny {
|
||||
builder.WriteString(zeroTheme.faint.Render("No transcript rows."))
|
||||
builder.WriteString("\n")
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
return fitStyledLine(hint, width)
|
||||
}
|
||||
|
||||
func detailedTranscriptHeader(width int) string {
|
||||
|
||||
@@ -124,6 +124,78 @@ 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++ {
|
||||
|
||||
@@ -30,8 +30,9 @@ func (m model) handleUserCommand(raw string) (model, tea.Cmd, bool) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return m, nil, false
|
||||
}
|
||||
next, teaCmd := m.launchPrompt(prompt)
|
||||
return next, teaCmd, true
|
||||
// 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)
|
||||
}
|
||||
|
||||
// lookupUserCommand returns the loaded user command with the given (lowercased)
|
||||
|
||||
@@ -203,6 +203,13 @@ 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.
|
||||
|
||||
Reference in new issue
Block a user