Compare commits

...
Author SHA1 Message Date
Gnanam c427052f9b chore: ignore zero-skills local build artifact
A locally built test binary under this name was accidentally committed
(and has been scrubbed from the branch history); ignore the name so it
cannot recur.
2026-07-05 20:49:24 +05:30
Gnanam fce9569362 fix(tui): review follow-ups — restore typed invocation on compaction guard, cap merged suggestions
- The compaction-in-flight guard for skill/user-command invocations fired
  after the dispatch path had already cleared the composer, so the typed
  "/name args" was lost and had to be retyped; thread the raw invocation
  through and restore it into the composer (the plain-prompt guard keeps
  the composer intact by firing before the clear). The picker path has no
  typed form and restores nothing.
- Cap the merged autocomplete list at the shared bound: each source
  self-caps, but three sources could stack to ~3x the palette limit.
- Tighten the shadowed-skill palette test to assert on the advertised
  Name rather than the fallback Desc wording.
2026-07-05 19:46:03 +05:30
Gnanam 3259ea3f6a feat(tui): ask-first guard for bare skill invocations
A skill invoked with no request submits only its body — instructions
with no target ("review the PR" — which PR?) — and the model improvises
a target instead of asking, which is especially wrong in a fresh
session. Bare invocations (slash and picker) now append a conditional
note: if the instructions need a target or details not already clear
from the conversation, ask first; self-contained skills still just run.
Invocations that carry a request are unchanged.
2026-07-05 19:26:10 +05:30
Gnanam f6e298ad20 feat(tui): /skills opens a searchable skill picker
Replace the static /skills card with the same picker UX /model uses:
type-to-filter (ranked search), arrow-key selection, Enter runs the
chosen skill immediately. Selection is by the skill's exact name, so
skills the slash path cannot reach — names shadowed by a builtin or
user command, or names that don't fit a slash token — are still
runnable from the picker, where the choice is unambiguous. The text
card remains only as the no-skills install hint, and the picker path
applies the same run-state guards as slash invocation.
2026-07-05 19:26:10 +05:30
Gnanam 15ddc79e75 feat(skills): make skill invocation deterministic and discoverable
Skills were model-pulled only: the model had to notice a skill in a
640-byte system-prompt list (~6 skills visible; the rest collapsed into
'…and N more') and call the skill tool on its own. Users could not run a
skill at all — typing /<skill-name> reported 'unknown command'. The two
halves of the fix:

User invocation
- /<skill-name> [args] now runs an installed skill directly: resolved
  after builtins and user commands, the skill body is inlined as the
  prompt with the typed args appended, and submitted through the same
  launch path user commands use.
- /skills lists installed skills with their slash form, live from disk.
  Labels are honest: a skill shadowed by a builtin, alias, user command,
  or a case-colliding sibling is listed bare with a '(via skill tool)'
  note instead of advertising a dead /name.
- The slash palette suggests skills (labeled '(skill)') read through the
  same loader dispatch uses, so mid-session installs appear and shadowed
  or case-colliding names are never advertised.
- Skill and user-command invocations now apply the plain-prompt run-state
  guards: mid-run submissions queue the EXPANDED prompt (the queue flush
  submits literal text), compaction-in-flight warns, exiting drops.
- Skills resolve through a lazy plugin-aware loader (default dir merged
  with plugin skill roots — the same set the skill tool and the system
  prompt see), TTL-cached so palette keystrokes don't re-read SKILL.md
  bodies.

Model triggering
- Raise the skills-list budget 640 -> 4096 bytes: the list is the model's
  only discovery surface, and the old cap made skills past ~6 invisible —
  invocation reliability collapsed exactly for users invested in skills.
- Raise description truncation 100 -> 200 runes: the description carries
  the trigger conditions ('use when…'); cutting them is what stopped the
  model from firing the skill.
- Strengthen the instruction line: scan the list before acting, call the
  matching skill FIRST, don't substitute your own approach.
2026-07-05 19:26:10 +05:30
PatrickNoFilter 0f69d995e9 fix: Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs (#509)
* fix(tui): TracerPid PRoot detection, Ctrl+U/D direction, composer guard

- Replace Termux env var detection with isRunningUnderPRoot()
  via /proc/self/status TracerPid, cached with sync.OnceValue
- Mouse mode falls back to CellMotion (not AllMotion) under PRoot
  to fix broken touch scrolling
- Add Shift+Up/Shift+Down before plain KeyDown/KeyUp for transcript
  scroll with permission/askuser cursor + composer guard
- Add Ctrl+U (scroll up) and Ctrl+D (scroll down) with proper
  direction: Ctrl+U=move up, Ctrl+D=move down
- Add guards for active modals (wizard, picker, spec review, etc.)
  to avoid intercepting their Ctrl+U/D keybindings
- Tests: TestPermissionCursorCtrlU/D (direction regression),
  TestShiftUp/DownComposerGuard (composer guard)
- References issue #507

* fix(sandbox): avoid faccessat2 for Termux/Android portability

Replace exec.LookPath with lookupExecutable that uses os.Stat
instead of the faccessat2 syscall. The faccessat2 syscall (439)
is blocked by the Samsung seccomp filter on Android, causing
SIGSYS termination. os.Stat uses the newfstatat/statx syscalls
which are universally permitted.

This is part of broader Termux/Android support for Zero.

* docs(install): add Termux/Android build and configuration guide

Document how to build Zero for native Android/Termux: GOOS=android
to avoid the SIGSYS crash, proot bind-mount for DNS resolution,
PRoot scroll fix, and free-tier provider setup.

* fix(sandbox): skip empty PATH segments, use platform-aware executable check
2026-07-05 21:48:34 +08:00
Kevin Codex 87e7e69afd feat(openai): forward prompt_cache_key for server-side prefix cache routing (#515)
* feat(openai): forward prompt_cache_key for server-side prefix cache routing

Send the session ID as OpenAI's prompt_cache_key on every completion
request so consecutive turns route to a backend replica that already
holds the conversation prefix in its prompt cache, instead of re-billing
the full ~11k-token system+tools prefix each turn on OpenAI-compatible
endpoints (which get no cache_control breakpoints, unlike Anthropic).

Keyless requests serialize byte-identically to before, and
ZERO_DISABLE_PROMPT_CACHE_KEY=1 suppresses the field for strict
endpoints that reject unknown parameters. The Codex (Responses API)
flavor is deliberately untouched: the ChatGPT backend is a separate,
stricter surface.

* fix(openai): parse prompt-cache-key kill switch as a boolean flag

ZERO_DISABLE_PROMPT_CACHE_KEY=0 or =false previously disabled forwarding
because any non-empty value tripped the check. Parse it the same way
ZERO_FORMAT_ON_WRITE parses boolean flags, so explicitly-falsy values
are a no-op and only truthy values flip the toggle.
2026-07-05 21:41:03 +08:00
Vasanth T f4c998ac30 feat(agent): curb over-engineering the solution in the editing discipline (#517)
The editing discipline already caps the diff size and scope, but a small diff can
still over-build the solution — speculative abstraction, unrequested
configurability, handling for cases that cannot occur. Add a guardrail: solve the
problem as posed, nothing the user did not ask for; if a 200-line solution could
be 50, rewrite it.

Validated with a before/after eval on five over-engineering-prone tasks: 3/5
solutions came out simpler with the guardrail, none more complex, none broke
correctness (avg 9 vs 11 non-blank lines); the baseline gold-plated on two of them
(an unrequested cache-introspection attribute and header-merge config). No effect
on trivial tasks, where the baseline is already minimal.

Closes #516
2026-07-05 21:38:42 +08:00
Alaa Elsamouly 30bff28efc Fix/detailed view scroll select (#498)
* fix(tui): strip all ANSI escapes from copied transcript text

renderAssistantMarkdownText embeds bold markers for prose and ANSI color sequences from chroma for fenced code blocks. stripMarkdownRenderControls only stripped the bold markers, leaking ANSI codes into clipboard text. Switch to ansi.Strip() to remove all escape sequences.

* refactor(tui): extract rendering dispatch and add detailed bool to transcriptBodyItems

Extract rowRenderFn and transcriptRowDispatchFn types and refactor rendering methods into *Fn variants parameterized by the render function.

Add detailed bool parameter to transcriptBodyItems to prepare for the detailed transcript viewport. All callers pass false so there is no behavior change yet.

Extract transcriptRowBodyHeightCacheKeyOpts from transcriptRowBodyHeightCacheKey so the body cap can be configured per caller.

* feat(tui): scrollable detailed transcript viewport with sidebar suppression

Enable keyboard scrolling (PgUp/PgDown/Up/Down) in detailed transcript mode by routing through scrollChat instead of swallowing the keys.

Rewrite detailedTranscriptView to use the transcriptBodyItems pipeline so the body renders through the same viewport/scroll engine as the live view.

Implement detailedTranscriptFooter with copy-status and jump-to-bottom hint integration.

Suppress the sidebar in detailed mode so the full chat width is available for reading uncapped tool output.

* test(tui): detailed transcript scroll with PgUp/Down and Up/Down arrows

TestPgUpDownScrollsDetailedTranscript verifies that PgUp scrolls toward older content and PgDown returns to the bottom in detailed mode.

TestUpDownArrowsScrollDetailedTranscript verifies that arrow keys scroll one line at a time and bottom is clamped at offset 0.

* test(tui): ANSI strip verification for markdown rendering controls and selectable text

TestStripMarkdownRenderControlsStripsAllANSI verifies bold markers, color sequences, and combined sequences are stripped.

TestSelectedTranscriptTextStripsANSIFromHighlightedCode verifies that chroma-highlighted code blocks produce clean plain text in the selectable layer.

* fix for coderabit, and better fewer code.

* fix(tui): address PR review feedback dead code, hover clear, and
rebindable key label

* format

* addressing the review issue

1. the ctrl + b in detailed mode, added the guard to not be able to do
it in detailed mode so it affect its state in the normal mode.

2. added a test coverage for keyDown after `keDown then keyUp` so it
   covers that.

* fix(tui): close file drill-in when entering detailed transcript

toggleDetailedTranscript now calls exitFileView before returning, so an active file drill-in is automatically closed when the user enters detailed transcript mode.

* test(tui): verify file drill-in closes on detailed toggle

Adds TestDetailedTranscriptClosesFileView and TestDetailedTranscriptStaysClosedOnSecondToggle to guard against the file-view bypass bug.

* fix(tui): block composer hit-test while detailed transcript is active

composerMouseSelectionBlocked and mouseOverComposer now check m.transcriptDetailed so mouse clicks and wheel events in the composer area reach the transcript body instead of being silently consumed.

* test(tui): verify composer mouse is ignored in detailed mode

Adds TestComposerMouseSelectionBlockedInDetailedMode to guard against composer hit-test swallowing mouse events during detailed transcript viewing.
2026-07-05 19:36:17 +08:00
36 changed files with 1632 additions and 131 deletions

No files matched your search

+5
View File
@@ -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
+55
View File
@@ -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`:
+5
View File
@@ -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, ""
+30 -5
View File
@@ -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)
+18 -7
View File
@@ -191,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 {
@@ -202,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)
@@ -232,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
+4
View File
@@ -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.
+32
View File
@@ -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
}
}
+16
View File
@@ -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)
}
}
}
+5
View File
@@ -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
+49 -2
View File
@@ -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":
+5 -2
View File
@@ -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 {
+48 -1
View File
@@ -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
+16
View File
@@ -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`)",
},
})
}
+8
View File
@@ -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",
+1 -1
View File
@@ -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()
}
+50 -1
View File
@@ -143,7 +143,7 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
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).
+1 -1
View File
@@ -372,7 +372,7 @@ func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
return 0, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "")
items := m.transcriptBodyItems(width, "", false)
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
startY := -1
for _, span := range metrics.spans {
+167 -22
View File
@@ -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,
+7 -7
View File
@@ -2142,7 +2142,7 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
width := m.chatColumnWidth()
findRow := func(mm model) (transcriptBodyItem, bool) {
for _, it := range mm.transcriptBodyItems(width, "") {
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
View File
@@ -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()
+56
View File
@@ -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{
+8 -1
View File
@@ -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
+67
View File
@@ -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)
}
}
+30
View File
@@ -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 {
+44
View File
@@ -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{
+1 -1
View File
@@ -82,7 +82,7 @@ func (m model) sidebarToggleAllowed() bool {
// would have any visible effect. The subchat drill-in keeps its own single-column
// view, so the sidebar is suppressed there.
func (m model) sidebarAvailable() bool {
if !m.altScreen || m.height <= 0 || m.subchat.active {
if !m.altScreen || m.height <= 0 || m.subchat.active || m.transcriptDetailed {
return false
}
if sidebarWidth(m.width) <= 0 {
+205
View File
@@ -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
}
+412
View File
@@ -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)
+4 -4
View File
@@ -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, "")
+80 -28
View File
@@ -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)
+30 -43
View File
@@ -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 {
+72
View File
@@ -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++ {
+3 -2
View File
@@ -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)
+7
View File
@@ -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.