Skip to content

Permission approval UX is non-functional end-to-end: modes are display-only, dialog doesn't block, response protocol untested #37

@arcavenai

Description

@arcavenai

What happened

The permission/approval system in forestage's custom TUI (--mode forestage, the default) is wired visually but has three functional gaps that together mean the user has no reliable way to approve or deny tool-use requests while running in the forestage TUI.

1. Permission mode toggle (Shift+Tab) is display-only

src/tui/app.rs:213-266 defines PermissionMode (Default / AcceptEdits / Plan / Auto / Bypass). src/tui/input.rs:420-421 dispatches InputAction::CyclePermissionMode on Shift+BackTab. src/tui/mod.rs:291-296 handles the action by calling state.permission_mode = state.permission_mode.next() and updating the status-bar label.

No code path sends the new mode to the Claude Code subprocess. The status bar shows [acceptEdits] but Claude Code is still in whatever mode it was initialized with via system/init (src/protocol_ext.rs:187, src/tui/app.rs:345-350). Cycling is a lie — it mutates forestage's display and nothing else.

2. The permission dialog does not command attention

When a PermissionRequest hook event arrives (src/protocol_ext.rs:315-336BridgeEvent::PermissionRequest), it lands on AppState.pending_permission (src/tui/app.rs:475) and src/tui/layout.rs:79-110 reserves a 6-row slot above the input area. The rendering is passive — it just appears where the input moves up a bit. There is:

  • no focus pull
  • no bell / terminal alert
  • no color flash or animation
  • no conversation-area dim or modal overlay
  • no keypress queue interception explaining what to do
  • no timeout indicator, no "Claude is waiting" hint

If the user is reading earlier output when a prompt arrives, nothing signals that the agent is blocked on them. The prompt quietly appears in the layout and stays there.

3. The approve/deny response is explicitly unvalidated and may hang

src/bridge.rs:174-203 contains this WARNING, added in PR #24 review fix 0f4dff1:

WARNING: This protocol format is unvalidated against Claude Code's actual hook response protocol. The hook system may expect a different JSON shape. If the format is wrong, Claude Code will hang waiting for a valid response. Needs integration testing before relying on permission prompt functionality.

send_permission_response() sends:

{"type":"permission_response","permission_response":{"behavior":"allow"|"deny"}}

No one has confirmed Claude Code accepts that shape. Pressing a or d may leave the subprocess hung forever with no user-visible feedback beyond "Permission: allowed" in the status bar.

Expected behavior

The user sees a clear, attention-commanding prompt when Claude Code asks for permission. Pressing a key produces a response that Claude Code actually accepts, and the session proceeds. Cycling permission modes with Shift+Tab changes the actual permission posture of the running session, matching Claude Code's own Shift+Tab behavior.

Concretely:

  1. Mode cycling is wired through. Shift+Tab in forestage produces the same effect as Shift+Tab in vanilla Claude Code — the subprocess's permission mode actually changes. Mechanism is TBD: a control message on the NDJSON stream, a SIGHUP-style reload, or (failing that) a restart with the new --permission-mode flag.

  2. The dialog commands attention. Options to consider (not all mutually exclusive):

    • Render as a true modal overlay (centered, dimmed background) rather than a layout slot
    • Visual/audio alert on arrival (terminal bell, color flash, border animation)
    • Keyboard input is routed to the dialog until resolved (no accidental typing into the input box)
    • Status bar shows "Claude is waiting for approval" with the pending tool name
    • Optional: allow /permissions slash command or /approve, /deny typed commands as alternatives to a / d
  3. The response protocol is validated. Integration test against a live Claude Code subprocess:

    • Confirm the JSON shape Claude Code actually expects for hook permission responses
    • Confirm whether it's type: permission_response or something else (maybe the hook system uses a different envelope)
    • Confirm whether behavior: allow is correct vs. action: allow, decision: allow, permission: grant, etc.
    • Add a regression test (VCR cassette or mock subprocess) that captures the working shape
    • Remove the WARNING comment once validated
  4. Failure is visible. If the subprocess does not acknowledge the response within N seconds, surface a warning: "Claude Code did not respond to permission; the protocol may be wrong. Fall back to `--mode claude` for this session." Better a loud failure than a silent hang.

Reproduction

  1. forestage (launches default --mode forestage TUI)
  2. Prompt Claude to do something that triggers a tool permission check, e.g. run \ls /etc` via bash`
  3. Observe the prompt appears quietly above the input area
  4. Press Shift+Tab a few times; note that the status bar label changes but the actual permission posture of the subprocess does not (reproducible by comparing to --mode claude behavior)
  5. Press a on a permission prompt; note: response may or may not actually unblock the subprocess, depending on whether the unvalidated JSON shape happens to match

Scope

This is one meta-issue because the three gaps are not independently fixable — a working UX requires all three. If this turns out to be too large for one PR, split after the response-protocol integration test clarifies what the correct shape is (that test is the critical path; everything else is UI work).

References

  • src/bridge.rs:174-203send_permission_response with the WARNING
  • src/tui/app.rs:213-266PermissionMode enum and cycle
  • src/tui/app.rs:268-275PermissionPrompt struct
  • src/tui/app.rs:475 — where pending_permission is set
  • src/tui/app.rs:1076-1120render_permission_prompt
  • src/tui/input.rs:420-425Shift+Tab / a / d bindings
  • src/tui/layout.rs:79-110 — layout with permission prompt slot
  • src/tui/mod.rs:291-312 — action handlers for cycle/allow/deny
  • src/protocol_ext.rs:315-336PermissionRequest event parsing
  • PR probe(aclaude): TUI prototype — headless Claude Code with custom ratatui interface #24 review commit 0f4dff1 — C1 finding that produced the WARNING

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions