mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91c6e768fd | ||
|
|
16b69cc060 | ||
|
|
356b7334da |
No files matched your search
+56
-2
@@ -689,6 +689,17 @@ 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
|
||||
@@ -1439,6 +1450,11 @@ 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()
|
||||
@@ -1659,6 +1675,16 @@ 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
|
||||
@@ -1854,6 +1880,15 @@ 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)
|
||||
}
|
||||
@@ -2784,6 +2819,16 @@ 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
|
||||
@@ -3942,13 +3987,22 @@ 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.resolvePermission(option.choice)
|
||||
return m.choosePermissionOption(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
|
||||
@@ -3957,7 +4011,7 @@ func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cm
|
||||
if pending.decide != nil {
|
||||
pending.decide(agent.PermissionDecision{
|
||||
Action: decision,
|
||||
Reason: permissionDecisionReason(decision),
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
m.pendingPermission = nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
@@ -74,7 +76,9 @@ 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 {
|
||||
if m.pendingPermission == nil || m.pendingPermission.typing {
|
||||
// While typing feedback the arrow/Tab keys belong to the text field, not
|
||||
// the option list.
|
||||
return m
|
||||
}
|
||||
n := len(permissionOptions(m.pendingPermission.request))
|
||||
@@ -87,11 +91,67 @@ 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.
|
||||
// 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.
|
||||
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.resolvePermission(option.choice)
|
||||
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
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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 {
|
||||
@@ -73,7 +74,7 @@ func TestPermissionOptionsExposeApprovalCancelWhenSupplied(t *testing.T) {
|
||||
t.Fatalf("cancel option = %#v, want cancel on n", options[3])
|
||||
}
|
||||
|
||||
card, _ := renderFocusedPermissionPrompt(request, 3, 80)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 3, false, "", 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) {
|
||||
@@ -96,7 +97,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, 100)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 100)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"allow `git status` in this session", "[p]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -115,7 +116,7 @@ func TestPermissionPromptMapsEscalatedSandboxReason(t *testing.T) {
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, 96)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 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)
|
||||
@@ -140,7 +141,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, 100)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 100)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{"always allow `git status`", "[y]"} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -158,7 +159,7 @@ func TestPermissionOptionsCanExposePatchCancelWithoutRecoverableDeny(t *testing.
|
||||
agent.PermissionDecisionCancel,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, 80)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 2, false, "", 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)
|
||||
@@ -181,7 +182,7 @@ func TestRequestPermissionsPromptUsesGrantLabelsAndEscDenies(t *testing.T) {
|
||||
agent.PermissionDecisionDeny,
|
||||
},
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, 96)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 96)
|
||||
got := plainRender(t, card)
|
||||
for _, want := range []string{
|
||||
"Grant requested permissions?",
|
||||
@@ -256,7 +257,9 @@ func TestPermissionHotkeysStillResolveDirectly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionCancelHotkeyResolvesDirectly(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 {
|
||||
request := testPromptPermissionRequest()
|
||||
request.ToolName = "bash"
|
||||
request.AvailableDecisions = []agent.PermissionDecisionAction{
|
||||
@@ -264,21 +267,87 @@ func TestPermissionCancelHotkeyResolvesDirectly(t *testing.T) {
|
||||
agent.PermissionDecisionDeny,
|
||||
agent.PermissionDecisionCancel,
|
||||
}
|
||||
got := []permissionDecision{}
|
||||
m := pendingPermissionModelWithRequest(t, request, func(d agent.PermissionDecision) {
|
||||
got = append(got, permissionDecision(d.Action))
|
||||
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)
|
||||
})
|
||||
if _, cmd := m.Update(testKeyText("n")); cmd != nil {
|
||||
t.Fatal("'n' should resolve synchronously")
|
||||
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 len(got) != 1 || got[0] != permissionDecisionCancel {
|
||||
t.Fatalf("'n' should resolve cancel directly, got %#v", got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionRenderEmitsHighlightedClickableOffsets(t *testing.T) {
|
||||
request := agent.PermissionRequest{ToolName: "write_file", AvailableDecisions: testAllPermissionDecisions()}
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 2, 60) // cursor on future approval
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 2, false, "", 60) // cursor on future approval
|
||||
if len(offsets) != len(permissionOptions(request)) {
|
||||
t.Fatalf("offsets = %d, want %d", len(offsets), len(permissionOptions(request)))
|
||||
}
|
||||
@@ -299,7 +368,7 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) {
|
||||
Scope: "example.com",
|
||||
AvailableDecisions: testAllPermissionDecisions(),
|
||||
}
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, 72)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 1, false, "", 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) {
|
||||
@@ -387,7 +456,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, 70)
|
||||
card, _ := renderFocusedPermissionPrompt(request, 0, false, "", 70)
|
||||
|
||||
var selected string
|
||||
for _, line := range strings.Split(card, "\n") {
|
||||
@@ -432,3 +501,116 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -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, width int) (string, []int) {
|
||||
func renderFocusedPermissionPrompt(request agent.PermissionRequest, cursor int, typing bool, feedback string, width int) (string, []int) {
|
||||
name := strings.TrimSpace(request.ToolName)
|
||||
if name == "" {
|
||||
name = "tool"
|
||||
@@ -1141,6 +1141,17 @@ 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, 80)
|
||||
card, offsets := renderFocusedPermissionPrompt(request, 0, false, "", 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) {
|
||||
|
||||
@@ -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, width)
|
||||
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, perm.typing, m.input.Value(), width)
|
||||
options := permissionOptions(perm.request)
|
||||
selectable := make([]transcriptSelectableLine, 0, len(offsets))
|
||||
for index, offset := range offsets {
|
||||
@@ -1401,8 +1401,14 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
if line.permOption {
|
||||
// A left-click on a permission-popup option resolves it directly.
|
||||
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.
|
||||
next, cmd := m.resolvePermission(line.permChoice)
|
||||
return next.(model), cmd, true
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user