mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1bc1c4af2 |
No files matched your search
@@ -67,28 +67,6 @@ An approved issue means only that the specific issue has been accepted for work.
|
||||
It does not approve unrelated future work, future pull requests, or future issues
|
||||
from the same author. Each issue is reviewed case by case.
|
||||
|
||||
## Contribution Volume and Batching
|
||||
|
||||
To keep triage and review sustainable while the project stabilizes, please keep
|
||||
your contribution flow to a size the core team can realistically work through:
|
||||
|
||||
- One issue per bug. File each defect as its own issue with its own
|
||||
reproduction. Please do not bundle several unrelated findings into a single
|
||||
"audit" issue; separate issues are far easier to triage, approve, and close
|
||||
individually.
|
||||
- Keep only a few items open at a time. If you already have several issues or
|
||||
pull requests awaiting review, let those move through the queue before opening
|
||||
more. A large simultaneous burst from one contributor slows review for
|
||||
everyone, even when each item is valid.
|
||||
- AI-assisted contributions are welcome, held to the same bar. Automated audits
|
||||
and generated patches are fine, but each issue and pull request must be
|
||||
individually verified, scoped, and reproducible before you open it. Please do
|
||||
not post bulk machine-generated findings unfiltered.
|
||||
|
||||
These apply to all contributors and are about review bandwidth, not the value of
|
||||
any single report. Maintainers may ask you to consolidate, split, or hold
|
||||
submissions to keep the queue manageable.
|
||||
|
||||
## Pull Request Requirements
|
||||
|
||||
Before opening a community pull request, make sure all of the following are true:
|
||||
|
||||
@@ -939,12 +939,6 @@ func providerSensitiveEnvKeys(resolved config.ResolvedConfig) []string {
|
||||
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
|
||||
// the default policy.
|
||||
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
|
||||
// An explicit `"sandbox": {"enabled": false}` disables the sandbox: every
|
||||
// request is allowed and commands run unwrapped. Only reachable from global
|
||||
// config / CLI — project config never sets Enabled (see mergeProjectConfig).
|
||||
if cfg.Enabled != nil && !*cfg.Enabled {
|
||||
policy.Mode = sandbox.ModeDisabled
|
||||
}
|
||||
if network := strings.TrimSpace(cfg.Network); network != "" {
|
||||
switch sandbox.NetworkMode(network) {
|
||||
case sandbox.NetworkAllow, sandbox.NetworkDeny:
|
||||
|
||||
@@ -1001,18 +1001,3 @@ func sandboxPolicyCapabilityStatus(capabilities []sandbox.BackendCapability, key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestApplyConfiguredSandboxPolicyEnabledFalseDisables(t *testing.T) {
|
||||
disabled := false
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &disabled}); got.Mode != sandbox.ModeDisabled {
|
||||
t.Fatalf("enabled:false must yield ModeDisabled, got %q", got.Mode)
|
||||
}
|
||||
// Omitted (nil) and explicit true keep the default enforcing mode.
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.Mode == sandbox.ModeDisabled {
|
||||
t.Fatal("omitted enabled must not disable the sandbox")
|
||||
}
|
||||
enabled := true
|
||||
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{Enabled: &enabled}); got.Mode == sandbox.ModeDisabled {
|
||||
t.Fatal("enabled:true must not disable the sandbox")
|
||||
}
|
||||
}
|
||||
@@ -93,16 +93,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
if err != nil {
|
||||
return ResolvedConfig{}, err
|
||||
}
|
||||
// Sandbox.Enabled is NOT accepted from a provider command, for the same
|
||||
// reason project config cannot set it (see mergeProjectConfig): only
|
||||
// global config and the CLI may turn the sandbox off. A provider command
|
||||
// is an arbitrary executable named in config, and LoadProviderCommand
|
||||
// parses its stdout into a full FileConfig — so without this, a command
|
||||
// returning a valid provider plus {"sandbox":{"enabled":false}} would
|
||||
// disable the very sandbox meant to constrain what it can do. Cleared
|
||||
// here rather than in mergeConfig because that helper is shared with the
|
||||
// trusted global-config merge, which must keep honouring the setting.
|
||||
commandConfig.Sandbox.Enabled = nil
|
||||
mergeConfig(&cfg, commandConfig)
|
||||
}
|
||||
|
||||
@@ -226,9 +216,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
mergeProvider(dst, provider)
|
||||
}
|
||||
mergeMCPConfig(&dst.MCP, src.MCP, true)
|
||||
if src.Sandbox.Enabled != nil {
|
||||
dst.Sandbox.Enabled = src.Sandbox.Enabled
|
||||
}
|
||||
if network := strings.TrimSpace(src.Sandbox.Network); network != "" {
|
||||
dst.Sandbox.Network = network
|
||||
}
|
||||
@@ -286,10 +273,6 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
if err := mergeProjectMCPConfig(&dst.MCP, src.MCP); err != nil {
|
||||
return err
|
||||
}
|
||||
// Sandbox.Enabled is intentionally NOT merged from project config: a cloned
|
||||
// repo's .zero/config.json must not be able to disable the sandbox that
|
||||
// constrains it. Only global config and CLI can turn the sandbox off.
|
||||
//
|
||||
// Sandbox.AdditionalWriteRoots is intentionally NOT merged from project
|
||||
// config: a cloned repo's .zero/config.json must not be able to grant
|
||||
// itself write access outside the workspace. Global config and CLI flags
|
||||
@@ -705,9 +688,6 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
|
||||
if overrides.MaxTurns > 0 {
|
||||
cfg.MaxTurns = overrides.MaxTurns
|
||||
}
|
||||
if overrides.Sandbox.Enabled != nil {
|
||||
cfg.Sandbox.Enabled = overrides.Sandbox.Enabled
|
||||
}
|
||||
if overrides.Sandbox.BlockUnixSockets {
|
||||
cfg.Sandbox.BlockUnixSockets = true
|
||||
}
|
||||
|
||||
@@ -1744,85 +1744,6 @@ func TestResolveSandboxNetworkProjectConfigCannotWeaken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledUserConfigDisables(t *testing.T) {
|
||||
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
|
||||
t.Fatalf("user config enabled:false must resolve to a false pointer, got %v", resolved.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxDisableIgnoredFromProjectConfig(t *testing.T) {
|
||||
// Security: a cloned repo's project config must NOT be able to disable the
|
||||
// sandbox that constrains it. Only global config / CLI can turn it off.
|
||||
userPath := writeConfig(t, `{}`)
|
||||
projectPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: userPath, ProjectConfigPath: projectPath, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled != nil {
|
||||
t.Fatalf("project config enabled:false must be ignored, got %v — a repo could disable the sandbox", *resolved.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledIgnoredFromProviderCommand(t *testing.T) {
|
||||
// Security: a provider command is an arbitrary executable named in config, and
|
||||
// its stdout is parsed into a full FileConfig — so it must NOT be able to reach
|
||||
// Sandbox.Enabled, in either direction. Same rule as project config: only global
|
||||
// config / CLI may turn the sandbox off.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "cannot disable", value: "false"},
|
||||
{name: "cannot enable", value: "true"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"sandbox":{"enabled":` + tc.value + `}}`,
|
||||
})
|
||||
resolved, err := Resolve(ResolveOptions{ProviderCommand: command, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
// The provider itself must still be applied — only the sandbox toggle is dropped.
|
||||
if resolved.ActiveProvider != "cmd" {
|
||||
t.Fatalf("ActiveProvider = %q, want cmd (provider command must still apply)", resolved.ActiveProvider)
|
||||
}
|
||||
if resolved.Sandbox.Enabled != nil {
|
||||
t.Fatalf("provider command set sandbox.enabled=%s, got %v — a provider command could toggle the sandbox", tc.value, *resolved.Sandbox.Enabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSandboxEnabledCLIOverride(t *testing.T) {
|
||||
// A CLI/programmatic override (applied after every config source) must be able
|
||||
// to disable the sandbox, even with no config file.
|
||||
disabled := false
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &disabled}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved.Sandbox.Enabled == nil || *resolved.Sandbox.Enabled {
|
||||
t.Fatalf("CLI override enabled:false must disable the sandbox, got %v", resolved.Sandbox.Enabled)
|
||||
}
|
||||
// An explicit true override must win over a lower-precedence global-config false.
|
||||
userPath := writeConfig(t, `{"sandbox": {"enabled": false}}`)
|
||||
enabled := true
|
||||
resolved2, err := Resolve(ResolveOptions{UserConfigPath: userPath, Env: map[string]string{}, Overrides: Overrides{Sandbox: SandboxConfig{Enabled: &enabled}}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if resolved2.Sandbox.Enabled == nil || !*resolved2.Sandbox.Enabled {
|
||||
t.Fatalf("CLI override enabled:true must win over config false, got %v", resolved2.Sandbox.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNotifyValid(t *testing.T) {
|
||||
path := writeConfig(t, `{"notify":{"mode":"both","focusMode":"always"}}`)
|
||||
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
|
||||
|
||||
@@ -62,12 +62,6 @@ func HasProviderProfile(profile ProviderProfile) bool {
|
||||
}
|
||||
|
||||
type SandboxConfig struct {
|
||||
// Enabled turns the sandbox off when set to false (`"sandbox": {"enabled":
|
||||
// false}`). A pointer distinguishes an explicit false (disable) from an
|
||||
// omitted key (keep the default: enabled). Honored from the GLOBAL user
|
||||
// config and CLI only — deliberately NOT project config, so a cloned repo
|
||||
// cannot disable the sandbox that constrains it. nil / true keep enforcement.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// Network controls whether shell commands classified as network-touching
|
||||
// (curl, git push, package installs, …) are allowed: "allow" or "deny".
|
||||
// Empty keeps the built-in default (deny). Without this knob the engine's
|
||||
|
||||
@@ -65,76 +65,6 @@ func AnalyzeCommand(script string) AnalysisResult {
|
||||
return result
|
||||
}
|
||||
|
||||
// astCommandFields parses command with the shell parser and returns each simple
|
||||
// command as its literal field slice (program + args as text), resolving the
|
||||
// real command positions across quoting, command substitution, subshells, and
|
||||
// newline separators — the constructs the hand-written splitter in
|
||||
// safe_command.go mis-handles (issue #473). It returns nil when the command
|
||||
// cannot be parsed (e.g. a Windows cmd.exe string), so callers fall through to
|
||||
// the regex path rather than hard-blocking.
|
||||
func astCommandFields(command string) [][]string {
|
||||
file, err := syntax.NewParser().Parse(strings.NewReader(command), "")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var commands [][]string
|
||||
syntax.Walk(file, func(node syntax.Node) bool {
|
||||
call, ok := node.(*syntax.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
// EVERY word must be a static literal, not just the program name.
|
||||
// wordText keeps only the literal/quoted parts of a word and silently
|
||||
// drops expansions, so a dynamic word anywhere in the call reconstructs
|
||||
// to something the shell will never run: `$(printf foo)vim` (runs as
|
||||
// `foovim`) collapses to "vim", and `git $(printf foo)rebase -i` (runs
|
||||
// as `git foorebase -i`, non-interactive) collapses to `git rebase -i`
|
||||
// and fabricates an interactive match. Since the runtime value of an
|
||||
// expansion is unknowable here, skip the whole call rather than classify
|
||||
// a lossy reconstruction. Skipping is the safe direction: the
|
||||
// hand-written passes above already ran, and a missed detection falls
|
||||
// through to the normal permission prompt instead of hard-blocking a
|
||||
// command the user never wrote.
|
||||
for _, word := range call.Args {
|
||||
if !isLiteralWord(word) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
fields := make([]string, 0, len(call.Args))
|
||||
for _, word := range call.Args {
|
||||
fields = append(fields, wordText(word))
|
||||
}
|
||||
commands = append(commands, fields)
|
||||
return true
|
||||
})
|
||||
return commands
|
||||
}
|
||||
|
||||
// isLiteralWord reports whether every part of word is a static literal (bare or
|
||||
// quoted). A word containing a command substitution, parameter/arithmetic
|
||||
// expansion, process substitution, etc. is dynamic — its runtime value is
|
||||
// unknown, so its wordText (a partial literal) must not be trusted as a program
|
||||
// name.
|
||||
func isLiteralWord(word *syntax.Word) bool {
|
||||
if word == nil {
|
||||
return false
|
||||
}
|
||||
for _, part := range word.Parts {
|
||||
switch typed := part.(type) {
|
||||
case *syntax.Lit, *syntax.SglQuoted:
|
||||
case *syntax.DblQuoted:
|
||||
for _, inner := range typed.Parts {
|
||||
if _, ok := inner.(*syntax.Lit); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// analyzeInto parses script and folds its interactive/destructive/network usage
|
||||
// into result, sharing seen so program names are de-duplicated across recursion.
|
||||
func analyzeInto(script string, result *AnalysisResult, seen map[string]bool, depth int) {
|
||||
|
||||
@@ -219,65 +219,9 @@ func DetectInteractiveCommand(command string, goos string) InteractiveCommandRes
|
||||
}
|
||||
}
|
||||
|
||||
// AST second opinion (issue #473): the hand-written segment split above
|
||||
// mis-handles interactive programs hidden by unusual quoting, command
|
||||
// substitution, subshells, or newline separators. Re-extract the real simple
|
||||
// commands from the parsed shell tree and apply the SAME full pipeline
|
||||
// (interactive segments, sh -c payload recursion, per-program lookup), so a
|
||||
// bypass is caught while every program stays classified exactly as before
|
||||
// (ssh with a trailing command, python -c, etc. remain allowed). A command
|
||||
// the parser cannot handle (Windows cmd.exe, obfuscation) yields no commands
|
||||
// and falls through unchanged — the guard never hard-blocks on a parse error.
|
||||
for _, fields := range astCommandFields(command) {
|
||||
if result, ok := inspectCommandFields(fields, goos); ok {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return InteractiveCommandResult{}
|
||||
}
|
||||
|
||||
// inspectCommandFields applies the full interactive-detection pipeline to one
|
||||
// already-split command's fields: the multi-word interactive segments, then the
|
||||
// `sh -c <payload>` recursion, then the per-program lookup with its
|
||||
// non-interactive suppressions (hasNonInteractiveFlag covers REPL flags and
|
||||
// trailing-command clients like ssh). It mirrors the hand-written passes above
|
||||
// so an AST-extracted command is classified identically to a plainly-split one.
|
||||
func inspectCommandFields(fields []string, goos string) (InteractiveCommandResult, bool) {
|
||||
body := strings.ToLower(commandBody(fields))
|
||||
for _, seg := range interactiveSegments {
|
||||
if body == seg.match || strings.HasPrefix(body, seg.match+" ") {
|
||||
suggestion := seg.suggestion
|
||||
if goos == "windows" && seg.windowsSuggestion != "" {
|
||||
suggestion = seg.windowsSuggestion
|
||||
}
|
||||
return InteractiveCommandResult{Interactive: true, Command: seg.command, Reason: seg.reason, Suggestion: suggestion}, true
|
||||
}
|
||||
}
|
||||
first := firstProgram(fields)
|
||||
if first == "" {
|
||||
return InteractiveCommandResult{}, false
|
||||
}
|
||||
if payload := shellDashCPayload(first, fields); payload != "" {
|
||||
if inner := DetectInteractiveCommand(payload, goos); inner.Interactive {
|
||||
return inner, true
|
||||
}
|
||||
return InteractiveCommandResult{}, false
|
||||
}
|
||||
program, ok := interactivePrograms[first]
|
||||
if !ok || (program.windowsOnly && goos != "windows") {
|
||||
return InteractiveCommandResult{}, false
|
||||
}
|
||||
if hasNonInteractiveFlag(first, fields) {
|
||||
return InteractiveCommandResult{}, false
|
||||
}
|
||||
suggestion := program.suggestion
|
||||
if goos == "windows" && program.windowsSuggestion != "" {
|
||||
suggestion = program.windowsSuggestion
|
||||
}
|
||||
return InteractiveCommandResult{Interactive: true, Command: first, Reason: program.reason, Suggestion: suggestion}, true
|
||||
}
|
||||
|
||||
// wrapperPrograms are launcher prefixes that precede the real program. After
|
||||
// one of these we keep scanning for the actual executable.
|
||||
var wrapperPrograms = map[string]bool{
|
||||
|
||||
@@ -406,87 +406,3 @@ func TestDetectInteractiveMongoEvalAndFullPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hand-written segment splitter misses interactive programs hidden by
|
||||
// constructs only a real shell parser resolves (a newline separator collapsed
|
||||
// to a space; a brace group that shifts the real command position). The AST
|
||||
// second opinion must catch them (issue #473).
|
||||
func TestDetectInteractiveCommandCatchesParserBypasses(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"echo hi\nvim file.txt", // newline separator collapsed to a space by the regex path
|
||||
"{ vim file.txt; }", // brace group hides the real program position
|
||||
} {
|
||||
got := DetectInteractiveCommand(cmd, "linux")
|
||||
if !got.Interactive {
|
||||
t.Errorf("DetectInteractiveCommand(%q) = not interactive, want caught (parser bypass)", cmd)
|
||||
continue
|
||||
}
|
||||
if got.Command != "vim" {
|
||||
t.Errorf("DetectInteractiveCommand(%q).Command = %q, want vim", cmd, got.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AST second opinion must not flag an interactive program NAME that appears
|
||||
// only inside a quoted argument (not a real command position) — the parser
|
||||
// distinguishes program from argument, so this must stay non-interactive.
|
||||
func TestDetectInteractiveCommandNoFalsePositiveOnQuotedArgument(t *testing.T) {
|
||||
got := DetectInteractiveCommand(`echo "please run vim later"`, "linux")
|
||||
if got.Interactive {
|
||||
t.Fatalf("interactive name inside a quoted argument must not be flagged, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The AST second opinion must run the FULL pipeline — multi-word interactive
|
||||
// segments and sh -c payload recursion, not just the per-program lookup — so a
|
||||
// grouped/nested bypass is caught; and it must not fabricate a program name from
|
||||
// a command substitution concatenated with a literal (issue #473, review).
|
||||
func TestDetectInteractiveCommandASTPipelineBoundaries(t *testing.T) {
|
||||
interactive := []struct{ cmd, wantCmd string }{
|
||||
{"{ git rebase -i HEAD~1; }", "git rebase -i"}, // multi-word segment in a brace group
|
||||
{"{ sh -c 'vim file.txt'; }", "vim"}, // sh -c payload in a brace group
|
||||
}
|
||||
for _, tc := range interactive {
|
||||
got := DetectInteractiveCommand(tc.cmd, "linux")
|
||||
if !got.Interactive || got.Command != tc.wantCmd {
|
||||
t.Errorf("DetectInteractiveCommand(%q) = %+v, want interactive Command=%q", tc.cmd, got, tc.wantCmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The AST pass must not fabricate a program name from a dynamic (non-literal)
|
||||
// program word: `$(printf '%s' foo)vim` runs as `foovim`, not vim. Tested at the
|
||||
// extractor so the hand-written pass (which pre-empts DetectInteractiveCommand
|
||||
// with its own, separate handling of that construct) does not mask the guard.
|
||||
func TestAstCommandFieldsSkipsDynamicProgram(t *testing.T) {
|
||||
for _, fields := range astCommandFields("$(printf '%s' foo)vim file.txt") {
|
||||
if firstProgram(fields) == "vim" {
|
||||
t.Fatalf("astCommandFields fabricated a vim command from a substitution: %v", fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The literalness guard covers every word, not just the program name. A dynamic
|
||||
// ARGUMENT is dropped by wordText the same way a dynamic program word is, so
|
||||
// `git $(printf foo)rebase -i HEAD~1` (which runs as `git foorebase -i`, a
|
||||
// non-existent and non-interactive subcommand) would otherwise be reconstructed
|
||||
// as `git rebase -i` and blocked — a false positive on a command the user never
|
||||
// wrote. Asserted through DetectInteractiveCommand as well as the extractor,
|
||||
// because the whole point is what the caller ends up blocking.
|
||||
func TestAstCommandFieldsSkipsDynamicArgument(t *testing.T) {
|
||||
const dynamic = "{ git $(printf foo)rebase -i HEAD~1 ; }"
|
||||
for _, fields := range astCommandFields(dynamic) {
|
||||
if firstProgram(fields) == "git" {
|
||||
t.Fatalf("astCommandFields reconstructed a git command from a dynamic argument: %v", fields)
|
||||
}
|
||||
}
|
||||
if got := DetectInteractiveCommand(dynamic, "linux"); got.Interactive {
|
||||
t.Errorf("DetectInteractiveCommand(%q) = %+v, want not interactive", dynamic, got)
|
||||
}
|
||||
// The genuine form must still be detected — the guard skips lossy
|
||||
// reconstructions, it does not weaken real detection.
|
||||
const genuine = "{ git rebase -i HEAD~1 ; }"
|
||||
if got := DetectInteractiveCommand(genuine, "linux"); !got.Interactive || got.Command != "git rebase -i" {
|
||||
t.Errorf("DetectInteractiveCommand(%q) = %+v, want interactive Command=%q", genuine, got, "git rebase -i")
|
||||
}
|
||||
}
|
||||
@@ -829,61 +829,3 @@ func TestGlobSkipsWorkspaceExcludedDirectoriesAndBinaryFiles(t *testing.T) {
|
||||
t.Fatalf("expected only keep.txt, got:\n%s", res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A backwards range (end_line < start_line) is a caller-arithmetic slip, not a
|
||||
// fatal error: read_file recovers by reading just start_line and surfaces a note
|
||||
// explaining the adjustment, matching how an end_line past EOF is auto-clamped.
|
||||
// It must never hard-error the way it used to.
|
||||
func TestReadFileToolRecoversBackwardsRange(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta\nepsilon")
|
||||
|
||||
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "notes.txt",
|
||||
"start_line": 3,
|
||||
"end_line": 2, // backwards
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("backwards range must recover, not error; got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "must be greater than or equal to start_line") {
|
||||
t.Fatalf("backwards range still hard-errors: %q", result.Output)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"end_line 2 was before start_line 3", // the note
|
||||
"only line 3 was read",
|
||||
"3 | gamma",
|
||||
} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected %q in output, got %q", want, result.Output)
|
||||
}
|
||||
}
|
||||
// It read exactly start_line, nothing adjacent.
|
||||
if strings.Contains(result.Output, "beta") || strings.Contains(result.Output, "delta") {
|
||||
t.Fatalf("recovery read the wrong lines: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A valid range with end_line >= start_line is byte-for-byte unchanged: the
|
||||
// recovery path must not touch normal reads (no stray note, exact same window).
|
||||
func TestReadFileToolValidRangeUnchangedByRecovery(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta\nepsilon")
|
||||
|
||||
result := NewReadFileTool(root).Run(context.Background(), map[string]any{
|
||||
"path": "notes.txt",
|
||||
"start_line": 2,
|
||||
"end_line": 4,
|
||||
})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("valid range failed: %s: %s", result.Status, result.Output)
|
||||
}
|
||||
// Byte-for-byte: header, blank separator, the exact selected lines, and no
|
||||
// recovery note or trailing content. An altered header/separator or a stray
|
||||
// out-of-range line would fail this where a substring check would not.
|
||||
const want = "File: notes.txt (lines 2-4 of 5)\n\n2 | beta\n3 | gamma\n4 | delta"
|
||||
if result.Output != want {
|
||||
t.Fatalf("valid-range output changed by the recovery path:\n got: %q\nwant: %q", result.Output, want)
|
||||
}
|
||||
}
|
||||
@@ -106,18 +106,8 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
if endLine == 0 || endLine > total {
|
||||
endLine = total
|
||||
}
|
||||
// A backwards range (end_line < start_line) is a caller slip, not a fatal
|
||||
// error. Recover the way an end_line past EOF is already recovered a few lines
|
||||
// up (clamped down to the last line): clamp end_line UP to start_line and read
|
||||
// just that one line. Erroring here instead cost the caller a whole retry for
|
||||
// an off-by-arithmetic mistake, and it was inconsistent — start_line past EOF
|
||||
// and end_line past EOF both recover gracefully; only this case hard-failed.
|
||||
// The recovery is surfaced in the output (rangeNote) so it is never silent: the
|
||||
// caller sees it read a different range than it asked for and why.
|
||||
rangeNote := ""
|
||||
if endLine < startLine {
|
||||
rangeNote = fmt.Sprintf("(note: end_line %d was before start_line %d, so only line %d was read — pass end_line >= start_line to read a wider range)", endLine, startLine, startLine)
|
||||
endLine = startLine
|
||||
return errorResult("Error: Invalid arguments for read_file: end_line must be greater than or equal to start_line")
|
||||
}
|
||||
|
||||
truncated := false
|
||||
@@ -145,12 +135,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
|
||||
}
|
||||
budgetedOutput.WriteString(header)
|
||||
budgetedOutput.WriteString("\n")
|
||||
if rangeNote != "" {
|
||||
budgetedOutput.WriteString(rangeNote)
|
||||
budgetedOutput.WriteString("\n")
|
||||
}
|
||||
budgetedOutput.WriteString("\n")
|
||||
budgetedOutput.WriteString("\n\n")
|
||||
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
|
||||
return errorResult("Error reading file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
|
||||
+2
-56
@@ -689,17 +689,6 @@ type pendingPermissionPrompt struct {
|
||||
// resting approval choice. Moved by ↑/↓/Tab; confirmed by Enter or a click.
|
||||
// Hotkeys resolve the matching request-provided option directly.
|
||||
cursor int
|
||||
// typing is true once the user chose "tell Zero what to do differently": the
|
||||
// card replaces its option list with a free-text field (sharing the composer
|
||||
// input, like the ask_user questionnaire). Submitting sends a Deny decision
|
||||
// whose Reason is the typed text, so the model reads it as the tool result and
|
||||
// adjusts course in the same turn instead of the run being cancelled.
|
||||
typing bool
|
||||
// savedDraft holds whatever was in the shared composer input when feedback
|
||||
// mode was entered. The field is cleared for typing and restored on both
|
||||
// submit and cancel, so a half-typed or queued next-turn message survives the
|
||||
// detour (permissionRequestMsg, unlike ask_user, does not clear the composer).
|
||||
savedDraft string
|
||||
}
|
||||
|
||||
// askUserRequestMsg is the TUI-loop equivalent of permissionRequestMsg: the
|
||||
@@ -1450,11 +1439,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.pendingAskUser != nil {
|
||||
return m.escapeAskUser()
|
||||
}
|
||||
// Esc in the permission feedback field steps back to the option list
|
||||
// rather than resolving, so a stray keystroke is recoverable.
|
||||
if m.pendingPermission != nil && m.pendingPermission.typing {
|
||||
return m.cancelPermissionTyping()
|
||||
}
|
||||
if m.pendingSpecReview != nil {
|
||||
m.burstCount = 0
|
||||
return m.cancelSpecReview()
|
||||
@@ -1675,16 +1659,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
case keyBackspace(msg):
|
||||
// In permission feedback mode Backspace is a plain edit of the feedback
|
||||
// text. This case runs before the typing branch below and, on an empty
|
||||
// field (feedback mode clears the composer), would otherwise fall to the
|
||||
// removeLastAttachment path and silently drop a staged image/doc that
|
||||
// savedDraft does not restore. Route it to the shared input instead.
|
||||
if m.pendingPermission != nil && m.pendingPermission.typing {
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.picker != nil {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
@@ -1880,15 +1854,6 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.handleSpecReviewKey(msg)
|
||||
}
|
||||
if m.pendingPermission != nil {
|
||||
// Feedback mode: a printable keystroke (and editing keys like
|
||||
// backspace) types into the shared composer input, mirroring the
|
||||
// ask_user free-text path above. Enter/Esc/↑/↓ were already handled
|
||||
// earlier in this switch; the remaining keys reach the input here.
|
||||
if m.pendingPermission.typing {
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
m.burstCount = 0
|
||||
return m.handlePermissionKey(msg)
|
||||
}
|
||||
@@ -2819,16 +2784,6 @@ func (m model) footerView(width int) string {
|
||||
footer.WriteString(m.statusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// A focused permission prompt owns the keyboard: its options (and the feedback
|
||||
// field) consume every key, so the composer is inert. Suppress it and the idle
|
||||
// hints/plan panel like the ask_user modal above, keeping only the status line.
|
||||
// The card itself renders in the transcript body. This also keeps the shared
|
||||
// input from echoing in two places once "tell Zero what to do differently"
|
||||
// opens the on-card feedback field.
|
||||
if m.pendingPermission != nil {
|
||||
footer.WriteString(m.statusLine(width))
|
||||
return footer.String()
|
||||
}
|
||||
// Pinned plan panel: sits directly above the composer so it stays visible
|
||||
// while the transcript scrolls underneath (a streaming turn no longer pushes
|
||||
// the plan off-screen). Budgeted to at most a third of the screen height; a
|
||||
@@ -3987,22 +3942,13 @@ func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
key := strings.ToLower(msg.String())
|
||||
for _, option := range permissionOptions(m.pendingPermission.request) {
|
||||
if option.hotkey == key {
|
||||
return m.choosePermissionOption(option.choice)
|
||||
return m.resolvePermission(option.choice)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cmd) {
|
||||
return m.resolvePermissionWithReason(decision, permissionDecisionReason(decision))
|
||||
}
|
||||
|
||||
// resolvePermissionWithReason resolves the pending prompt with an explicit reason
|
||||
// string. It backs both the fixed-label choices (reason = permissionDecisionReason)
|
||||
// and the free-text "tell Zero what to do differently" path, where the reason is
|
||||
// the user's typed instruction and the action is Deny so the agent surfaces it as
|
||||
// the tool result and keeps going.
|
||||
func (m model) resolvePermissionWithReason(decision permissionDecision, reason string) (tea.Model, tea.Cmd) {
|
||||
pending := m.pendingPermission
|
||||
if pending == nil {
|
||||
return m, nil
|
||||
@@ -4011,7 +3957,7 @@ func (m model) resolvePermissionWithReason(decision permissionDecision, reason s
|
||||
if pending.decide != nil {
|
||||
pending.decide(agent.PermissionDecision{
|
||||
Action: decision,
|
||||
Reason: reason,
|
||||
Reason: permissionDecisionReason(decision),
|
||||
})
|
||||
}
|
||||
m.pendingPermission = nil
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -76,9 +74,7 @@ func clampPermissionCursor(cursor int, request agent.PermissionRequest) int {
|
||||
// the ends. A no-op when no permission prompt is pending. The cursor lives on the
|
||||
// pending prompt (a pointer), mirroring how the picker's selection moves.
|
||||
func (m model) movePermissionCursor(delta int) model {
|
||||
if m.pendingPermission == nil || m.pendingPermission.typing {
|
||||
// While typing feedback the arrow/Tab keys belong to the text field, not
|
||||
// the option list.
|
||||
if m.pendingPermission == nil {
|
||||
return m
|
||||
}
|
||||
n := len(permissionOptions(m.pendingPermission.request))
|
||||
@@ -91,67 +87,11 @@ func (m model) movePermissionCursor(delta int) model {
|
||||
}
|
||||
|
||||
// confirmPermissionCursor resolves the currently highlighted option. It is the
|
||||
// Enter-key counterpart to the a/y/d hotkeys and a mouse click. Confirming the
|
||||
// "tell Zero what to do differently" choice opens the inline feedback field
|
||||
// instead of resolving immediately.
|
||||
// Enter-key counterpart to the a/y/d hotkeys and a mouse click.
|
||||
func (m model) confirmPermissionCursor() (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil {
|
||||
return m, nil
|
||||
}
|
||||
if m.pendingPermission.typing {
|
||||
return m.submitPermissionFeedback()
|
||||
}
|
||||
option := permissionOptions(m.pendingPermission.request)[clampPermissionCursor(m.pendingPermission.cursor, m.pendingPermission.request)]
|
||||
return m.choosePermissionOption(option.choice)
|
||||
}
|
||||
|
||||
// choosePermissionOption applies a chosen decision. The cancel choice (the
|
||||
// "tell Zero what to do differently" row and its [n] hotkey) opens the inline
|
||||
// feedback field rather than aborting the run; every other choice resolves
|
||||
// immediately as before.
|
||||
func (m model) choosePermissionOption(choice permissionDecision) (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil {
|
||||
return m, nil
|
||||
}
|
||||
if choice == permissionDecisionCancel {
|
||||
m.pendingPermission.typing = true
|
||||
// Preserve whatever the user had drafted/queued in the composer so it is
|
||||
// restored when they leave feedback mode (submit or cancel).
|
||||
m.pendingPermission.savedDraft = m.input.Value()
|
||||
m.input.SetValue("")
|
||||
return m, nil
|
||||
}
|
||||
return m.resolvePermission(choice)
|
||||
}
|
||||
|
||||
// submitPermissionFeedback ends the feedback field. Non-empty text is sent as a
|
||||
// Deny decision whose Reason is the text: the agent surfaces that as the tool
|
||||
// result (deniedPermissionResult) so the model reads the instruction and adjusts
|
||||
// in the same turn, rather than the run being cancelled. Empty text falls back to
|
||||
// a plain cancel, matching the option's prior behaviour.
|
||||
func (m model) submitPermissionFeedback() (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil {
|
||||
return m, nil
|
||||
}
|
||||
feedback := strings.TrimSpace(m.input.Value())
|
||||
// Restore the composer draft the user had before entering feedback mode; the
|
||||
// feedback text itself is delivered via the decision Reason, not the composer.
|
||||
m.input.SetValue(m.pendingPermission.savedDraft)
|
||||
m.pendingPermission.typing = false
|
||||
if feedback == "" {
|
||||
return m.resolvePermission(permissionDecisionCancel)
|
||||
}
|
||||
return m.resolvePermissionWithReason(permissionDecisionDeny, feedback)
|
||||
}
|
||||
|
||||
// cancelPermissionTyping returns from the feedback field to the option list
|
||||
// without resolving, so Esc is a safe "I didn't mean to type" back-out.
|
||||
func (m model) cancelPermissionTyping() (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil || !m.pendingPermission.typing {
|
||||
return m, nil
|
||||
}
|
||||
m.pendingPermission.typing = false
|
||||
m.input.SetValue(m.pendingPermission.savedDraft)
|
||||
m.pendingPermission.savedDraft = ""
|
||||
return m, nil
|
||||
return m.resolvePermission(option.choice)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func pendingPermissionModel(t *testing.T, decide func(agent.PermissionDecision)) model {
|
||||
@@ -74,7 +73,7 @@ func TestPermissionOptionsExposeApprovalCancelWhenSupplied(t *testing.T) {
|
||||
t.Fatalf("cancel option = %#v, want cancel on n", options[3])
|
||||
}
|
||||
|
||||
card, _ := renderFocusedPermissionPrompt(request, 3, false, "", 80)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 3, 80)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"continue without running it", "[d]", "tell Zero what to do differently", "[n]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -97,7 +96,7 @@ func TestPermissionOptionsExposeCommandPrefixApproval(t *testing.T) {
|
||||
if len(options) != 3 || options[1].choice != permissionDecisionAllowPrefix || options[1].hotkey != "p" {
|
||||
t.Fatalf("prefix option = %#v, want p hotkey in supplied order", options)
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 100)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, 100)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"allow `git status` in this session", "[p]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -116,7 +115,7 @@ func TestPermissionPromptMapsEscalatedSandboxReason(t *testing.T) {
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 96)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, 96)
|
||||
got := plainRender(t, card)
|
||||
if !strings.Contains(got, "This command needs to run outside the sandbox.") {
|
||||
t.Fatalf("permission card = %q, missing user-facing sandbox reason", got)
|
||||
@@ -141,7 +140,7 @@ func TestPermissionOptionsExposePersistentCommandPrefixApproval(t *testing.T) {
|
||||
if len(options) != 4 || options[2].choice != permissionDecisionAlwaysAllowPrefix || options[2].hotkey != "y" {
|
||||
t.Fatalf("persistent prefix option = %#v, want y hotkey in supplied order", options)
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 100)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, 100)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"always allow `git status`", "[y]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -159,7 +158,7 @@ func TestPermissionOptionsCanExposePatchCancelWithoutRecoverableDeny(t *testing.
|
||||
agent.PermissionDecisionCancel,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 80)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, 80)
|
||||
got := plainRender(t, card)
|
||||
if !strings.Contains(got, "tell Zero what to do differently") || !strings.Contains(got, "[n]") {
|
||||
t.Fatalf("permission card = %q, missing cancel option", got)
|
||||
@@ -182,7 +181,7 @@ func TestRequestPermissionsPromptUsesGrantLabelsAndEscDenies(t *testing.T) {
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 96)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, 96)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{
|
||||
"Grant requested permissions?",
|
||||
@@ -257,9 +256,7 @@ func TestPermissionHotkeysStillResolveDirectly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// feedbackRequest is a bash prompt whose decision set includes Cancel, so the
|
||||
// "tell Zero what to do differently" row (and its [n] hotkey) is present.
|
||||
func feedbackRequest() agent.PermissionRequest {
|
||||
func TestPermissionCancelHotkeyResolvesDirectly(t *testing.T) {
|
||||
request := testPromptPermissionRequest()
|
||||
request.ToolName = "bash"
|
||||
request.AvailableDecisions = []agent.PermissionDecisionAction{
|
||||
@@ -267,87 +264,21 @@ func feedbackRequest() agent.PermissionRequest {
|
||||
agent.PermissionDecisionDeny,
|
||||
agent.PermissionDecisionCancel,
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// The [n] "tell Zero what to do differently" hotkey no longer resolves cancel
|
||||
// immediately: it opens the inline feedback field and sends nothing yet.
|
||||
func TestPermissionTellDifferentlyOpensFeedbackField(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
got := []permissionDecision{}
|
||||
m := pendingPermissionModelWithRequest(t, request, func(d agent.PermissionDecision) {
|
||||
got = append(got, permissionDecision(d.Action))
|
||||
})
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
nm := next.(model)
|
||||
if nm.pendingPermission == nil || !nm.pendingPermission.typing {
|
||||
t.Fatalf("'n' should open the feedback field, got pending=%#v", nm.pendingPermission)
|
||||
if _, cmd := m.Update(testKeyText("n")); cmd != nil {
|
||||
t.Fatal("'n' should resolve synchronously")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("'n' must not resolve anything yet, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Typing an instruction and pressing Enter sends a Deny whose Reason is the
|
||||
// text, so the agent surfaces it to the model as the tool result.
|
||||
func TestPermissionFeedbackSubmitSendsDenyWithText(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
})
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
nm := typeRunes(t, next.(model), "use apply_patch instead")
|
||||
after, _ := nm.Update(testKey(tea.KeyEnter))
|
||||
am := after.(model)
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Enter should resolve exactly one decision, got %#v", got)
|
||||
}
|
||||
if got[0].Action != agent.PermissionDecisionDeny {
|
||||
t.Fatalf("feedback should resolve as Deny, got %s", got[0].Action)
|
||||
}
|
||||
if got[0].Reason != "use apply_patch instead" {
|
||||
t.Fatalf("Deny reason should carry the typed text, got %q", got[0].Reason)
|
||||
}
|
||||
if am.pendingPermission != nil {
|
||||
t.Fatal("prompt should be dismissed after submitting feedback")
|
||||
}
|
||||
}
|
||||
|
||||
// Enter with an empty field falls back to a plain cancel (the option's prior
|
||||
// meaning), so opening the field and changing your mind is safe.
|
||||
func TestPermissionFeedbackEmptySubmitCancels(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
})
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
after, _ := next.(model).Update(testKey(tea.KeyEnter))
|
||||
_ = after
|
||||
if len(got) != 1 || got[0].Action != agent.PermissionDecisionCancel {
|
||||
t.Fatalf("empty feedback should resolve Cancel, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Esc in the feedback field returns to the option list without resolving.
|
||||
func TestPermissionFeedbackEscReturnsToOptions(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
})
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
after, _ := typeRunes(t, next.(model), "half a thought").Update(testKey(tea.KeyEsc))
|
||||
am := after.(model)
|
||||
if am.pendingPermission == nil || am.pendingPermission.typing {
|
||||
t.Fatalf("Esc should return to the option list, got pending=%#v", am.pendingPermission)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("Esc must not resolve anything, got %#v", got)
|
||||
if len(got) != 1 || got[0] != permissionDecisionCancel {
|
||||
t.Fatalf("'n' should resolve cancel directly, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionRenderEmitsHighlightedClickableOffsets(t *testing.T) {
|
||||
request := agent.PermissionRequest{ToolName: "write_file", AvailableDecisions: testAllPermissionDecisions()}
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 2, false, "", 60) // cursor on future approval
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 2, 60) // cursor on future approval
|
||||
if len(offsets) != len(permissionOptions(request)) {
|
||||
t.Fatalf("offsets = %d, want %d", len(offsets), len(permissionOptions(request)))
|
||||
}
|
||||
@@ -368,7 +299,7 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
|
||||
Scope: "example.com",
|
||||
AvailableDecisions: testAllPermissionDecisions(),
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 72)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, 72)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"target: example.com", "allow this host for this conversation", "[s]", "allow this host in the future", "[y]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -456,7 +387,7 @@ func TestShiftDownComposerGuard(t *testing.T) {
|
||||
// other selectable list in the TUI uses.
|
||||
func TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip(t *testing.T) {
|
||||
request := agent.PermissionRequest{ToolName: "exec_command", SideEffect: "shell"}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 70)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, 70)
|
||||
|
||||
var selected string
|
||||
for _, line := range strings.Split(card, "\n") {
|
||||
@@ -501,116 +432,3 @@ func backgroundCode(hex string) string {
|
||||
fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
|
||||
return fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
|
||||
}
|
||||
|
||||
// A focused permission prompt suppresses the composer: keys drive the card, so a
|
||||
// "describe a task for zero…" box below it is inert and misleading — and once the
|
||||
// feedback field is open, the shared input must not echo in both places.
|
||||
func TestComposerSuppressedDuringPermissionPrompt(t *testing.T) {
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
|
||||
m.width, m.height = 96, 30
|
||||
|
||||
view := plainRender(t, m.View())
|
||||
if strings.Contains(view, "describe a task for zero") {
|
||||
t.Errorf("composer must be hidden while a permission prompt is focused:\n%s", view)
|
||||
}
|
||||
|
||||
// While typing feedback, the text lives on the card, not in a second box.
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
typed := typeRunes(t, next.(model), "use apply_patch")
|
||||
tv := plainRender(t, typed.View())
|
||||
if strings.Contains(tv, "describe a task for zero") {
|
||||
t.Errorf("composer must stay hidden in feedback mode:\n%s", tv)
|
||||
}
|
||||
if strings.Count(tv, "use apply_patch") != 1 {
|
||||
t.Errorf("feedback text should appear exactly once (on the card), got %d occurrences:\n%s", strings.Count(tv, "use apply_patch"), tv)
|
||||
}
|
||||
}
|
||||
|
||||
// Entering feedback mode must not lose a composer draft (a half-typed or queued
|
||||
// next-turn message). The draft is restored whether feedback is submitted or
|
||||
// cancelled; only the feedback text (delivered via the decision Reason) is
|
||||
// consumed.
|
||||
func TestPermissionFeedbackPreservesComposerDraftOnSubmit(t *testing.T) {
|
||||
var got []agent.PermissionDecision
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(d agent.PermissionDecision) {
|
||||
got = append(got, d)
|
||||
})
|
||||
m.input.SetValue("my queued next message")
|
||||
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
nm := next.(model)
|
||||
if nm.input.Value() != "" {
|
||||
t.Fatalf("feedback field should start empty, got %q", nm.input.Value())
|
||||
}
|
||||
nm = typeRunes(t, nm, "use apply_patch")
|
||||
after, _ := nm.Update(testKey(tea.KeyEnter))
|
||||
am := after.(model)
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected exactly one permission decision, got %#v", got)
|
||||
}
|
||||
if got[0].Reason != "use apply_patch" {
|
||||
t.Fatalf("feedback text lost: %q", got[0].Reason)
|
||||
}
|
||||
if am.input.Value() != "my queued next message" {
|
||||
t.Fatalf("composer draft not restored after submit, got %q", am.input.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionFeedbackPreservesComposerDraftOnCancel(t *testing.T) {
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
|
||||
m.input.SetValue("my queued next message")
|
||||
|
||||
next, _ := m.Update(testKeyText("n"))
|
||||
nm := typeRunes(t, next.(model), "half a thought")
|
||||
after, _ := nm.Update(testKey(tea.KeyEsc))
|
||||
am := after.(model)
|
||||
|
||||
if am.pendingPermission == nil || am.pendingPermission.typing {
|
||||
t.Fatal("Esc should return to the option list")
|
||||
}
|
||||
if am.input.Value() != "my queued next message" {
|
||||
t.Fatalf("composer draft not restored after cancel, got %q", am.input.Value())
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace on an empty permission feedback field must edit the feedback text,
|
||||
// never drop a staged attachment. The keyBackspace case runs before the typing
|
||||
// branch and, on an empty composer, would otherwise remove the last staged
|
||||
// image/doc — which savedDraft does not restore.
|
||||
func TestPermissionFeedbackBackspaceKeepsStagedAttachment(t *testing.T) {
|
||||
m := pendingPermissionModelWithRequest(t, feedbackRequest(), func(agent.PermissionDecision) {})
|
||||
m.pendingImages = []zeroruntime.ImageBlock{{MediaType: "image/png"}}
|
||||
m.pendingImageLabels = []string{"diagram.png"}
|
||||
|
||||
next, _ := m.Update(testKeyText("n")) // open feedback field (clears composer text)
|
||||
after, _ := next.(model).Update(testKey(tea.KeyBackspace))
|
||||
am := after.(model)
|
||||
|
||||
if len(am.pendingImageLabels) != 1 || len(am.pendingImages) != 1 {
|
||||
t.Fatalf("Backspace in feedback mode dropped the staged attachment: labels=%v imgs=%d", am.pendingImageLabels, len(am.pendingImages))
|
||||
}
|
||||
if !am.pendingPermission.typing {
|
||||
t.Fatal("Backspace should stay in the feedback field, not exit it")
|
||||
}
|
||||
}
|
||||
|
||||
// No permission option row is clickable while the feedback field is open: the
|
||||
// renderer registers zero clickable offsets in typing mode, so a stray click
|
||||
// (Allow included) has no row to land on. This is the primary safety; the
|
||||
// !typing guard on the click resolver in handleTranscriptSelectionMouse is the
|
||||
// explicit second layer for if this early-return is ever refactored away.
|
||||
func TestPermissionFeedbackRendersNoClickableOptionsWhileTyping(t *testing.T) {
|
||||
request := feedbackRequest()
|
||||
|
||||
// Option mode: rows are clickable.
|
||||
_, optOffsets := renderFocusedPermissionPrompt(request, 0, false, "", 80)
|
||||
if len(optOffsets) == 0 {
|
||||
t.Fatal("precondition: option rows should be clickable in option mode")
|
||||
}
|
||||
// Feedback mode: no clickable rows.
|
||||
_, typingOffsets := renderFocusedPermissionPrompt(request, 0, true, "some feedback", 80)
|
||||
if typingOffsets != nil {
|
||||
t.Fatalf("feedback mode must register no clickable option offsets, got %#v", typingOffsets)
|
||||
}
|
||||
}
|
||||
@@ -1959,49 +1959,12 @@ func (wizard *providerWizardState) filteredModels() []providerWizardModel {
|
||||
return models
|
||||
}
|
||||
|
||||
// A model Description is only a good row label when it reads like a display
|
||||
// NAME — "Grok 4.3" for x-ai/grok-4.3 — which is what the field is for, and why
|
||||
// the row prefers it over an ugly raw ID.
|
||||
//
|
||||
// Providers do not all honour that. Aggregators commonly return a prose blurb
|
||||
// instead ("Fast DeepSeek model for efficient chat, coding help, and agent
|
||||
// loops"), which fails as a label twice over: it overflows the row, and because
|
||||
// such blurbs are written per model FAMILY rather than per model, several
|
||||
// DIFFERENT models collapse to byte-identical rows. A picker then offers two
|
||||
// visually identical entries with the distinguishing ID visible only in the
|
||||
// detail line of whichever one is highlighted.
|
||||
//
|
||||
// providerWizardGenericModelDescription cannot catch these: it rejects
|
||||
// descriptions that are vague ("live model", "catalog default"), whereas these
|
||||
// are specific — just shared and sentence-shaped. So the test is shape, not
|
||||
// wording: a name is short and few-worded; anything longer is prose, and the ID
|
||||
// (unique by construction) makes the better label.
|
||||
const (
|
||||
providerWizardMaxModelNameChars = 28
|
||||
providerWizardMaxModelNameWords = 4
|
||||
)
|
||||
|
||||
func providerWizardDescriptionIsDisplayName(description string) bool {
|
||||
trimmed := strings.TrimSpace(description)
|
||||
if trimmed == "" || providerWizardGenericModelDescription(trimmed) {
|
||||
return false
|
||||
}
|
||||
if len(trimmed) > providerWizardMaxModelNameChars {
|
||||
return false
|
||||
}
|
||||
return len(strings.Fields(trimmed)) <= providerWizardMaxModelNameWords
|
||||
}
|
||||
|
||||
func (model providerWizardModel) displayLabel() string {
|
||||
if providerWizardDescriptionIsDisplayName(model.Description) {
|
||||
return strings.TrimSpace(model.Description)
|
||||
description := strings.TrimSpace(model.Description)
|
||||
if description != "" && !providerWizardGenericModelDescription(description) {
|
||||
return description
|
||||
}
|
||||
if id := strings.TrimSpace(model.ID); id != "" {
|
||||
return id
|
||||
}
|
||||
// ID-less sentinel rows built in currentModel ("model name required",
|
||||
// "no matching models") would otherwise render blank.
|
||||
return strings.TrimSpace(model.Description)
|
||||
return model.ID
|
||||
}
|
||||
|
||||
func (model providerWizardModel) secondaryText() string {
|
||||
|
||||
@@ -1787,37 +1787,3 @@ func providerWizardIDs(descriptors []providercatalog.Descriptor) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// A provider that returns prose blurbs instead of display names must not
|
||||
// collapse distinct models into identical rows. Reproduces the reported picker
|
||||
// where two "Open-weight GPT model..." and two "Kimi multimodal agent model..."
|
||||
// rows were indistinguishable, with the ID visible only for the highlighted one.
|
||||
func TestProviderWizardModelRowsStayDistinctWithProseDescriptions(t *testing.T) {
|
||||
wizard := &providerWizardState{
|
||||
step: providerWizardStepModel,
|
||||
modelSource: "models.dev",
|
||||
models: []providerWizardModel{
|
||||
{ID: "gpt-oss-120b", Description: "Open-weight GPT model for self-hosted reasoning and instruction-following workloads"},
|
||||
{ID: "gpt-oss-20b", Description: "Open-weight GPT model for self-hosted reasoning and instruction-following workloads"},
|
||||
{ID: "kimi-k2-thinking", Description: "Kimi multimodal agent model for visual understanding, coding, and planning"},
|
||||
{ID: "kimi-k2-instruct", Description: "Kimi multimodal agent model for visual understanding, coding, and planning"},
|
||||
{ID: "x-ai/grok-4.3", Description: "Grok 4.3"},
|
||||
},
|
||||
}
|
||||
view := plainRender(t, strings.Join(wizard.renderModelStep(96), "\n"))
|
||||
|
||||
// every prose-described model is identified by its unique ID
|
||||
for _, id := range []string{"gpt-oss-120b", "gpt-oss-20b", "kimi-k2-thinking", "kimi-k2-instruct"} {
|
||||
if !strings.Contains(view, id) {
|
||||
t.Errorf("model %q is not identifiable in the list:\n%s", id, view)
|
||||
}
|
||||
}
|
||||
// a genuine display name is still preferred over the raw id
|
||||
if !strings.Contains(view, "Grok 4.3") {
|
||||
t.Errorf("friendly display name was lost:\n%s", view)
|
||||
}
|
||||
// the prose blurb must not be a row label
|
||||
if strings.Contains(view, "❯ Open-weight GPT model") {
|
||||
t.Errorf("prose blurb still used as a row label:\n%s", view)
|
||||
}
|
||||
}
|
||||
@@ -1107,7 +1107,7 @@ func wrapDetailBlock(detail string, width int) string {
|
||||
// renderFocusedPermissionPrompt draws the modal permission card and reports the
|
||||
// card-relative Y offset of each option line (in permissionOptions order) so the
|
||||
// caller can register those lines as clickable.
|
||||
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, typing bool, feedback string, width int) (string, []int) {
|
||||
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, width int) (string, []int) {
|
||||
name := strings.TrimSpace(request.ToolName)
|
||||
if name == "" {
|
||||
name = "tool"
|
||||
@@ -1141,17 +1141,6 @@ func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int,
|
||||
|
||||
lines = append(lines, "")
|
||||
|
||||
// Feedback mode: the option list is replaced by a free-text field, like the
|
||||
// ask_user "type your own answer" surface. What is typed is sent to the model
|
||||
// as the denial reason, so it reads the instruction and adjusts.
|
||||
if typing {
|
||||
lines = append(lines, fill(zeroTheme.muted).Render("Tell Zero what to do differently:"))
|
||||
lines = append(lines, zeroTheme.userPrompt.Render("❯ ")+fill(zeroTheme.ink).Render(feedback)+fill(zeroTheme.accent).Render("▌"))
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, fill(zeroTheme.faint).Render("enter · send to Zero esc · back to options"))
|
||||
return styledBlockFill(width, lines, zeroTheme.permBorder, zeroTheme.permBg), nil
|
||||
}
|
||||
|
||||
// Each option is its own line so a click anywhere on that row selects it (no
|
||||
// per-column hit-testing). The highlighted row gets a ▸ marker and a reverse
|
||||
// label; the rest stay quiet. styledBlockFill prepends exactly one top-border
|
||||
|
||||
@@ -1659,7 +1659,7 @@ func TestFocusedPermissionCardShowsBadgeAndKeys(t *testing.T) {
|
||||
Risk: sandbox.Risk{Level: sandbox.RiskMedium},
|
||||
AvailableDecisions: testAllPermissionDecisions(),
|
||||
}
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 0, false, "", 80)
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 0, 80)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"PERMISSION", "edit_file", "writes internal/agent/exec.go", "Yes, proceed", "[a]", "this session", "[s]", "don't ask again", "[y]", "continue without running it", "[d]", "[esc]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
|
||||
+5
-19
@@ -94,26 +94,12 @@ func (m model) sidebarAvailable() bool {
|
||||
if widthTier(m.width) < tierMedium {
|
||||
return false
|
||||
}
|
||||
// Full-screen overlays (setup, wizards, pickers) take over the chat column and
|
||||
// render at full width; suppress the second column while any is active so
|
||||
// their geometry and mouse hit-testing stay full-width as before.
|
||||
//
|
||||
// The `/` command palette is deliberately NOT in this list. It is not a
|
||||
// full-screen overlay: suggestionOverlay draws it via centerRenderedBlock at
|
||||
// minInt(width, suggestionPaletteMaxWidth) — a centred box capped at 76 cells
|
||||
// — floating over the chat column rather than replacing it. Suppressing the
|
||||
// sidebar for it collapsed the whole layout on every keystroke of `/`: the
|
||||
// plan panel dropped out of the sidebar and re-rendered inline at the bottom
|
||||
// and the transcript re-wrapped to full width, which is at its most jarring
|
||||
// mid-run with a live plan on screen. Since the sidebar already requires the
|
||||
// medium tier (>= 80 cells) the palette is always strictly narrower than the
|
||||
// chat column here, so the two never contend for the same cells.
|
||||
//
|
||||
// Mouse hit-testing is unaffected: sidebarLineAtMouse carries its own
|
||||
// suggestionsActive() guard, so clicks still go to the palette and not to the
|
||||
// sidebar rows underneath it.
|
||||
// Full-screen overlays (setup, wizards, pickers, the empty-state suggestion
|
||||
// list) take over the chat column and render at full width; suppress the
|
||||
// second column while any is active so their geometry and mouse hit-testing
|
||||
// stay full-width as before.
|
||||
if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil ||
|
||||
m.mcpManager != nil || m.picker != nil {
|
||||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
return false
|
||||
}
|
||||
// Home/welcome screen: stay single-column until there's real conversation, so
|
||||
|
||||
@@ -631,58 +631,3 @@ func TestTwoColumnTranscriptViewWidth(t *testing.T) {
|
||||
func stripSidebar(lines []string) string {
|
||||
return ansiPattern.ReplaceAllString(strings.Join(lines, "\n"), "")
|
||||
}
|
||||
|
||||
// The `/` command palette must NOT collapse the sidebar. It is a centred box
|
||||
// capped at suggestionPaletteMaxWidth floating over the chat column, not a
|
||||
// full-screen overlay — suppressing the second column for it dropped the plan
|
||||
// out of the sidebar and re-rendered it inline at the bottom on every `/`,
|
||||
// which is at its most disruptive mid-run with a live plan on screen. The
|
||||
// genuinely full-width overlays must still suppress it.
|
||||
func TestSidebarSurvivesCommandPalette(t *testing.T) {
|
||||
base := func() model {
|
||||
m := runningPlanModel(t, 3)
|
||||
m.altScreen = true
|
||||
m.height = 40
|
||||
m.headerPrinted = true
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
|
||||
return m
|
||||
}
|
||||
|
||||
m := base()
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("precondition: sidebar should be active for a wide alt-screen model with a plan")
|
||||
}
|
||||
|
||||
// `/` palette open: sidebar stays, so the plan keeps its home and the layout
|
||||
// does not reflow.
|
||||
m.suggestions = []commandSuggestion{{Name: "/model", Desc: "Pick a model."}, {Name: "/plan", Desc: "Show planning mode status."}}
|
||||
if !m.suggestionsActive() {
|
||||
t.Fatal("precondition: suggestions should be active")
|
||||
}
|
||||
if !m.sidebarActive() {
|
||||
t.Error("command palette must not collapse the sidebar")
|
||||
}
|
||||
if got := m.renderPinnedPlanPanel(m.chatColumnWidth(), 10); got != "" {
|
||||
t.Errorf("plan must stay in the sidebar, not fall back to the pinned panel:\n%s", got)
|
||||
}
|
||||
// The palette never contends for the sidebar's cells: the two-column path
|
||||
// renders it at width = chatColumnWidth, so it is centred inside the chat
|
||||
// column and cannot overlap the sidebar.
|
||||
chatW := m.chatColumnWidth()
|
||||
for _, line := range strings.Split(plainRender(t, m.suggestionOverlay(chatW)), "\n") {
|
||||
if w := lipgloss.Width(line); w > chatW {
|
||||
t.Errorf("palette line is %d wide, wider than the chat column %d — it would overlap the sidebar", w, chatW)
|
||||
}
|
||||
}
|
||||
// Clicks still belong to the palette, not the sidebar rows beneath it.
|
||||
if _, ok := m.sidebarLineAtMouse(tea.MouseClickMsg{Button: tea.MouseLeft}); ok {
|
||||
t.Error("sidebar must not take mouse hits while the palette is open")
|
||||
}
|
||||
|
||||
// A genuinely full-width overlay still suppresses the sidebar.
|
||||
full := base()
|
||||
full.picker = &commandPicker{}
|
||||
if full.sidebarActive() {
|
||||
t.Error("a full-screen picker must still collapse the sidebar")
|
||||
}
|
||||
}
|
||||
@@ -454,7 +454,7 @@ func (m model) buildTranscriptBodyItems(width int, emptyOverlay string, detailed
|
||||
rowIndex: -1,
|
||||
heightCacheStable: false, // the highlight changes with the cursor
|
||||
render: func(startBodyY int) transcriptBodyRenderedItem {
|
||||
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, perm.typing, m.input.Value(), width)
|
||||
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, width)
|
||||
options := permissionOptions(perm.request)
|
||||
selectable := make([]transcriptSelectableLine, 0, len(offsets))
|
||||
for index, offset := range offsets {
|
||||
@@ -1401,14 +1401,8 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
if line.permOption && !(m.pendingPermission != nil && m.pendingPermission.typing) {
|
||||
// A left-click on a permission-popup option resolves it directly. The
|
||||
// typing guard is defence-in-depth: renderFocusedPermissionPrompt already
|
||||
// returns nil offsets in feedback mode, so no option row is registered as
|
||||
// clickable then — but that single early-return is the only thing keeping
|
||||
// a stray click (Allow included) off the decision path, and it lives in a
|
||||
// function other PRs also edit. Guarding here makes the safety explicit
|
||||
// rather than emergent.
|
||||
if line.permOption {
|
||||
// A left-click on a permission-popup option resolves it directly.
|
||||
next, cmd := m.resolvePermission(line.permChoice)
|
||||
return next.(model), cmd, true
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user