Skip to content

Adding reject reason input to CodeNomad's permission UI #496

@bluelovers

Description

@bluelovers

tags:

  • opencode/permission
  • opencode/proposal
  • opencode/guide
  • opencode/english

Adding reject reason input to CodeNomad's permission UI

Overview

CodeNomad currently allows users to "Deny" a permission request, but the rejection is sent without any explanation. The underlying opencode SDK and HTTP API already support an optional message field with the "reject" reply type. When a message is provided, the agent receives a CorrectedError with the feedback text — enabling it to understand why it was denied and adjust its behavior accordingly.

This guide shows the minimal changes needed to add a reject-reason text input to CodeNomad's permission approval modal.

Current behavior

User clicks "Deny" → sendPermissionResponse(id, sessionId, permId, "reject")
                      → client.permission.reply({ requestID, reply: "reject" })
                      → Agent receives RejectedError (generic message)

Target behavior

User clicks "Deny" → UI shows a textarea
User types reason → "Please use a different directory"
User confirms  → sendPermissionResponse(id, sessionId, permId, "reject", "Please use a different directory")
               → client.permission.reply({ requestID, reply: "reject", message: "Please use a different directory" })
               → Agent receives CorrectedError({ feedback: "Please use a different directory" })

Changes needed

1. Update sendPermissionResponse to accept and pass a message

File: packages/ui/src/stores/instances.ts
Current (line 1087-1091):

async function sendPermissionResponse(
  instanceId: string,
  sessionId: string,
  requestId: string,
  reply: PermissionReply
): Promise<void> {

Change: Add an optional message parameter and pass it to the SDK:

async function sendPermissionResponse(
  instanceId: string,
  sessionId: string,
  requestId: string,
  reply: PermissionReply,
  message?: string              // ← add this
): Promise<void> {

Then update the SDK call (line 1104-1108):

await client.permission.reply({
  requestID: requestId,
  reply,
  ...(message ? { message } : {}),   // ← add message if present
})

2. Update handlePermissionDecision to handle message

File: packages/ui/src/components/permission-approval-modal.tsx
Current (line 158):

async function handlePermissionDecision(
  permission: PermissionRequestLike,
  response: "once" | "always" | "reject"
) {

Change: Add an optional message parameter:

async function handlePermissionDecision(
  permission: PermissionRequestLike,
  response: "once" | "always" | "reject",
  message?: string
) {
  // ...
  await sendPermissionResponse(props.instanceId, sessionId, permissionId, response, message)
}

3. Add a reject-reason state and text input in the modal

File: packages/ui/src/components/permission-approval-modal.tsx

When the user clicks "Deny", instead of immediately calling handlePermissionDecision, transition to a "reject reason" state that shows a text input (similar to how opencode's TUI RejectPrompt works).

You can either:

Option A — Two-step flow (like opencode TUI):

  1. User clicks "Deny"
  2. UI switches to show a textarea + "Confirm" / "Cancel" buttons
  3. User types reason and clicks "Confirm"
  4. Call handlePermissionDecision(item, "reject", reason)

Option B — Inline input (simpler):

  1. User clicks "Deny"
  2. A textarea slides in below the permission patterns
  3. User types reason (optional)
  4. User clicks "Confirm" (or the original "Deny" becomes "Confirm")
  5. Call handlePermissionDecision(item, "reject", reason)

Reference implementation (opencode TUI):

// From: packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx:444-519
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
  // Renders:
  // - A header: "△ Reject permission"
  // - A hint: "Tell OpenCode what to do differently"
  // - A textarea for input
  // - Enter to confirm, Escape to cancel
}

4. (Optional) Add a signal that a reason is expected

You may want to show a subtle hint like "Tell the AI why this was denied — it will see your feedback and adjust" below the textarea to encourage users to provide useful feedback.

How it affects the agent

With a message:

CorrectedError({ feedback: "Please use a different directory" })
↓
error.message = "The user rejected permission to use this specific tool call
                 with the following feedback: Please use a different directory"
↓
Tool part state.error = the above message string
↓
LLM sees this string in the tool result message
→ Can adjust its next action based on the feedback

Without a message:

RejectedError()
↓
error.message = "The user rejected permission to use this specific tool call."
↓
LLM sees a generic rejection, may retry the same action with different parameters

Error propagation path (complete)

User inputs reason in textarea
    │
    ▼
handlePermissionDecision(perm, "reject", reason)
    │
    ▼
sendPermissionResponse(instanceId, sessionId, permId, "reject", reason)
    │
    ▼
client.permission.reply({ requestID, reply: "reject", message: reason })
    │  POST /permission/{requestID}/reply { reply: "reject", message: reason }
    ▼
Permission.reply() core service
    │  CorrectedError({ feedback: reason })  (instead of RejectedError)
    ▼
Deferred.fail(deferred, CorrectedError)
    │
    ▼
ctx.ask() → Effect.orDie → EffectBridge.runPromise
    │  AI SDK catches "tool-error"
    ▼
LLM receives tool error with feedback text

Reference links

  • Opencode TUI RejectPrompt: packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx (lines 444-519)
  • Permission core service: packages/opencode/src/permission/index.ts (lines 213-269)
  • CorrectedError definition: packages/opencode/src/permission/index.ts (lines 87-93)
  • SDK permission.reply: packages/sdk/js/src/v2/gen/sdk.gen.ts (lines 2810-2831)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions