diff --git a/internal/tui/model.go b/internal/tui/model.go index 12cedaed2..228601e43 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 diff --git a/internal/tui/permission_prompt.go b/internal/tui/permission_prompt.go index a134ce338..527c6a6bd 100644 --- a/internal/tui/permission_prompt.go +++ b/internal/tui/permission_prompt.go @@ -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 } diff --git a/internal/tui/permission_prompt_test.go b/internal/tui/permission_prompt_test.go index 34101a26f..dfde83592 100644 --- a/internal/tui/permission_prompt_test.go +++ b/internal/tui/permission_prompt_test.go @@ -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) + }) + 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) != 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) }) - if _, cmd := m.Update(testKeyText("n")); cmd != nil { - t.Fatal("'n' should resolve synchronously") + 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) != 1 || got[0] != permissionDecisionCancel { - t.Fatalf("'n' should resolve cancel directly, got %#v", got) + 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) + } +} diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index 28e09d7ad..4cee924f1 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -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 diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index a76ea030c..a363a4df2 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -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) { diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index c6348f594..f81991545 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -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 }