From 065e91fda78d0eed6421356c1f7efdacde25a555 Mon Sep 17 00:00:00 2001 From: wviana <1062631+wviana@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:06:51 -0300 Subject: [PATCH 1/2] feat: Allow user to press tab and add aditional context when denying. --- src/cli/ui/App.tsx | 36 ++++++++----- src/cli/ui/DenyContextInput.tsx | 90 +++++++++++++++++++++++++++++++ src/cli/ui/EditConfirm.tsx | 93 ++++++++++++++++++++++---------- src/cli/ui/Select.tsx | 89 ++++++++++++++++++++++++++---- src/cli/ui/ShellConfirm.tsx | 28 +++++++--- src/cli/ui/WorkspaceConfirm.tsx | 14 +++-- src/cli/ui/keystroke-context.tsx | 43 ++++++++++++++- src/cli/ui/stdin-reader.ts | 22 ++++---- 8 files changed, 339 insertions(+), 76 deletions(-) create mode 100644 src/cli/ui/DenyContextInput.tsx diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 119931c..a517b5e 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -559,7 +559,12 @@ export function App({ // a useMemo dep. Without this, walkthroughActive alone wouldn't // re-render after a partial apply. const [pendingTick, setPendingTick] = useState(0); - const editReviewResolveRef = useRef<((c: EditReviewChoice) => void) | null>(null); + /** Result from the EditConfirm modal: choice plus optional deny context. */ + interface EditReviewResult { + choice: EditReviewChoice; + denyContext?: string; + } + const editReviewResolveRef = useRef<((r: EditReviewResult) => void) | null>(null); // Per-turn override: set by "apply-rest-of-turn" so subsequent edits // in the SAME turn skip the modal and land like AUTO. Resets to "ask" // at handleSubmit entry so the next user turn starts fresh. @@ -1433,7 +1438,7 @@ export function App({ if (resolve) { editReviewResolveRef.current = null; setPendingEditReview(null); - resolve("reject"); + resolve({ choice: "reject" }); } // Esc during a busy turn also kills any active /loop — the user // is taking over. Loops persist past plain Esc when the system is @@ -1712,7 +1717,7 @@ export function App({ // land all at once with no mid-stream opportunity to prompt. if (turnEditPolicyRef.current === "apply-all") return applyNow(); - const choice = await new Promise((resolveChoice) => { + const { choice, denyContext } = await new Promise((resolveChoice) => { editReviewResolveRef.current = resolveChoice; setPendingEditReview(block); }); @@ -1722,15 +1727,16 @@ export function App({ setPendingEditReview(null); if (choice === "reject") { + const context = denyContext ? ` because: ${denyContext}` : ""; setHistorical((prev) => [ ...prev, { id: `er-${Date.now()}-${Math.random()}`, role: "info", - text: `▸ rejected edit to ${block.path}`, + text: `▸ rejected edit to ${block.path}${context}`, }, ]); - return `User rejected this edit to ${block.path}. Don't retry the same SEARCH/REPLACE — either try a different approach or ask the user what they want instead.`; + return `User rejected this edit to ${block.path}${context}. Don't retry the same SEARCH/REPLACE — either try a different approach or ask the user what they want instead.`; } if (choice === "apply-rest-of-turn") { turnEditPolicyRef.current = "apply-all"; @@ -2137,7 +2143,7 @@ export function App({ if (resolve) { editReviewResolveRef.current = null; setPendingEditReview(null); - resolve(choice); + resolve({ choice, denyContext: undefined }); } }, resolveWorkspaceConfirm: (choice) => { @@ -3545,7 +3551,7 @@ export function App({ * config so next invocation auto-runs. */ const handleShellConfirm = useCallback( - async (choice: ShellConfirmChoice) => { + async (choice: ShellConfirmChoice, denyContext?: string) => { const pending = pendingShell; if (!pending || !codeMode) return; const { command: cmd, kind } = pending; @@ -3553,11 +3559,12 @@ export function App({ let synthetic: string; if (choice === "deny") { + const context = denyContext ? ` because: ${denyContext}` : ""; setHistorical((prev) => [ ...prev, - { id: `sh-deny-${Date.now()}`, role: "info", text: `▸ denied: ${cmd}` }, + { id: `sh-deny-${Date.now()}`, role: "info", text: `▸ denied: ${cmd}${context}` }, ]); - synthetic = `I denied running \`${cmd}\`. Please continue without running it.`; + synthetic = `I denied running \`${cmd}\`${context}. Please continue without running it.`; } else { if (choice === "always_allow") { const prefix = derivePrefix(cmd); @@ -3666,7 +3673,7 @@ export function App({ * against the new sandbox. */ const handleWorkspaceConfirm = useCallback( - async (choice: WorkspaceConfirmChoice) => { + async (choice: WorkspaceConfirmChoice, denyContext?: string) => { const pending = pendingWorkspace; if (!pending) return; const target = pending.path; @@ -3674,15 +3681,16 @@ export function App({ let synthetic: string; if (choice === "deny") { + const context = denyContext ? ` because: ${denyContext}` : ""; setHistorical((prev) => [ ...prev, { id: `ws-deny-${Date.now()}`, role: "info", - text: `▸ denied workspace switch: ${target}`, + text: `▸ denied workspace switch: ${target}${context}`, }, ]); - synthetic = `I denied switching the workspace to \`${target}\`. Please continue without changing directories.`; + synthetic = `I denied switching the workspace to \`${target}\`${context}. Please continue without changing directories.`; } else { const info = applyCwdChange(target); setHistorical((prev) => [ @@ -4451,11 +4459,11 @@ export function App({ ) : pendingEditReview ? ( { + onChoose={(choice, denyContext) => { const resolve = editReviewResolveRef.current; if (resolve) { editReviewResolveRef.current = null; - resolve(choice); + resolve({ choice, denyContext }); } }} /> diff --git a/src/cli/ui/DenyContextInput.tsx b/src/cli/ui/DenyContextInput.tsx new file mode 100644 index 0000000..92ec468 --- /dev/null +++ b/src/cli/ui/DenyContextInput.tsx @@ -0,0 +1,90 @@ +/** + * Inline single-line text input for the "deny with context" flow. + * + * Replaces `ink-text-input` (which depends on Ink's `useInput`) with + * our own `useKeystroke` — Reasonix replaces Ink's input layer with + * its own KeystrokeContext, so ink-text-input's key handler never + * fires. This component is a ~50-line drop-in that uses the same + * KeyEvent bus as every other modal component. + * + * Renders a prompt label, the typed text, a cursor indicator, and + * Enter / Esc affordances. Parent switches to this when the user + * presses Tab on a denyWithContext item in SingleSelect. + */ + +import { Box, Text } from "ink"; +import React, { useState } from "react"; +import { useKeystroke } from "./keystroke-context.js"; + +export interface DenyContextInputProps { + /** + * Label shown before the input field, e.g. "Reason:". + * Defaults to "Reason for denying:". + */ + label?: string; + /** Called with the typed text when the user presses Enter. */ + onSubmit: (context: string) => void; + /** Called when the user presses Esc — return to the select phase. */ + onCancel: () => void; +} + +/** + * Minimal single-line text input. Cursor is rendered as a block + * character at the end of the text. Backspace deletes the last + * character. Enter submits, Esc cancels. + */ +export function DenyContextInput({ + label = "Reason for denying:", + onSubmit, + onCancel, +}: DenyContextInputProps) { + const [value, setValue] = useState(""); + const cursorVisible = true; + + useKeystroke((ev) => { + if (ev.paste) return; + if (ev.escape) { + onCancel(); + return; + } + if (ev.return) { + onSubmit(value); + return; + } + if (ev.backspace) { + setValue((v) => v.slice(0, -1)); + return; + } + // Printable input — append to the value + if (ev.input && !ev.tab && !ev.upArrow && !ev.downArrow && !ev.leftArrow && !ev.rightArrow) { + setValue((v) => v + ev.input); + } + }); + + return ( + + + {label} + {value} + {cursorVisible ? ( + + {" "} + + ) : null} + + + + {"["} + + Enter + + {"] confirm · ["} + + Esc + + {"] cancel"} + + + + ); +} diff --git a/src/cli/ui/EditConfirm.tsx b/src/cli/ui/EditConfirm.tsx index 8adb2fc..bd8e25d 100644 --- a/src/cli/ui/EditConfirm.tsx +++ b/src/cli/ui/EditConfirm.tsx @@ -2,6 +2,7 @@ import { Box, Text, useStdout } from "ink"; import React, { useMemo, useState } from "react"; import { formatEditBlockSplit } from "../../code/diff-preview.js"; import type { EditBlock } from "../../code/edit-blocks.js"; +import { DenyContextInput } from "./DenyContextInput.js"; import { ModalCard } from "./ModalCard.js"; import { SplitDiff } from "./SplitDiff.js"; import { useKeystroke } from "./keystroke-context.js"; @@ -21,7 +22,12 @@ export type EditReviewChoice = "apply" | "reject" | "apply-rest-of-turn" | "flip export interface EditConfirmProps { block: EditBlock; - onChoose: (choice: EditReviewChoice) => void; + /** + * `onChoose` receives the choice and an optional context string when + * the user rejects with an explanation (pressing `n` opens a context + * input; Enter submits the context, Esc goes back to review). + */ + onChoose: (choice: EditReviewChoice, denyContext?: string) => void; } /** @@ -76,8 +82,20 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) { // keypress drove the offset past the new ceiling. const effectiveScroll = Math.min(scroll, maxScroll); + // Phase: "review" shows the diff with hotkeys; "context" shows the + // DenyContextInput after the user pressed `n` (reject). + const [phase, setPhase] = useState<"review" | "context">("review"); + useKeystroke((ev) => { if (ev.paste) return; + + // Context-input phase: route everything to DenyContextInput's + // logic is handled inside that component via its own useKeystroke. + // We only reach this handler for non-context events — the context + // phase shows DenyContextInput which has its own keystroke hook, + // so this handler's actions are no-ops during context input. + if (phase === "context") return; + const input = ev.input; const key = ev; // Action keys first — decision wins over scroll so a user who @@ -88,7 +106,9 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) { return; } if (input === "n") { - onChoose("reject"); + // Instead of immediately rejecting, enter the context-input + // phase so the user can explain *why* they're rejecting. + setPhase("context"); return; } if (input === "a") { @@ -145,31 +165,24 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) { `viewing ${effectiveScroll + 1}-${effectiveScroll + visibleRows.length}/${totalLines}`, ); } - const footer = ( - - {"["} - - y - - {"/Enter] apply · ["} - - n - - {"] reject · ["} - - a - - {"] apply rest · ["} - - A - - {"] flip AUTO · ["} - - ↑↓/Space - - {"] scroll · [Esc] abort"} - - ); + + // Context-input phase: show DenyContextInput instead of the diff + if (phase === "context") { + return ( + + onChoose("reject", context)} + onCancel={() => setPhase("review")} + /> + + ); + } return ( {hiddenAbove > 0 ? ( {` ↓ ${hiddenBelow} line${hiddenBelow === 1 ? "" : "s"} below (↓/j or Space/PgDn)`} ) : null} + + + {"["} + + y + + {"/Enter] apply · ["} + + n + + {"] reject with reason · ["} + + a + + {"] apply rest · ["} + + A + + {"] flip AUTO · ["} + + ↑↓/Space + + {"] scroll · [Esc] abort"} + + ); } diff --git a/src/cli/ui/Select.tsx b/src/cli/ui/Select.tsx index 1d9593f..82b742b 100644 --- a/src/cli/ui/Select.tsx +++ b/src/cli/ui/Select.tsx @@ -23,18 +23,31 @@ export interface SelectItem { hint?: string; /** If true, item is not selectable (rendered dimmed, skipped on nav). */ disabled?: boolean; + /** + * When true, pressing Tab on this item starts inline editing — `, ` + * is appended after the label and the user types context directly on + * the item row. The context is passed as the second argument to + * `onSubmit` on Enter. Used on "Deny"/"Reject" items so the user + * can explain *why* they're refusing. + */ + denyWithContext?: boolean; } export interface SingleSelectProps { items: SelectItem[]; initialValue?: V; - onSubmit: (value: V) => void; + onSubmit: (value: V, context?: string) => void; onCancel?: () => void; /** * Optional footer rendered dim beneath the list, e.g. * `"[↑↓] navigate · [Enter] select · [Esc] cancel"`. Makes keyboard * affordances discoverable — otherwise new users hit `y`/`n` and * wonder why nothing happens. + * + * When a `denyWithContext` item is active and the user presses Tab, + * inline editing starts directly on the item — `, ` is appended and + * typed text becomes the context, passed as the second argument to + * `onSubmit` on Enter. */ footer?: string; } @@ -51,13 +64,46 @@ export function SingleSelect({ items.findIndex((i) => i.value === initialValue && !i.disabled), ); const [index, setIndex] = useState(initialIndex === -1 ? 0 : initialIndex); + const [editingContext, setEditingContext] = useState(null); + const activeItem = items[index]; + const isEditing = editingContext !== null; useKeystroke((ev) => { if (ev.paste) return; + + if (isEditing) { + // Inline editing mode: typing context on a denyWithContext item + if (ev.escape) { + setEditingContext(null); + } else if (ev.upArrow || ev.downArrow) { + setEditingContext(null); + setIndex((i) => findNextEnabled(items, i, ev.upArrow ? -1 : +1)); + } else if (ev.return) { + const chosen = items[index]; + if (chosen && !chosen.disabled) { + const ctx = editingContext || undefined; + setEditingContext(null); + onSubmit(chosen.value, ctx); + } + } else if (ev.backspace) { + setEditingContext((v) => (v ?? "").slice(0, -1)); + } else if (ev.tab) { + // Tab while editing appends ", " to the context text + setEditingContext((v) => (v ?? "") + ", "); + } else if (ev.input) { + setEditingContext((v) => (v ?? "") + ev.input); + } + return; + } + + // Normal navigation mode if (ev.upArrow) { setIndex((i) => findNextEnabled(items, i, -1)); } else if (ev.downArrow) { setIndex((i) => findNextEnabled(items, i, +1)); + } else if (ev.tab && activeItem?.denyWithContext) { + // Tab on a deny-with-context item → start inline editing + setEditingContext(""); } else if (ev.return) { const chosen = items[index]; if (chosen && !chosen.disabled) onSubmit(chosen.value); @@ -66,19 +112,33 @@ export function SingleSelect({ } }); + // Footer: show different affordances when in editing mode vs. normal + const canDenyWithContext = activeItem?.denyWithContext; + const resolvedFooter = (() => { + if (isEditing) return "[Enter] confirm · [Esc] cancel · [↑↓] change option"; + return footer ?? (canDenyWithContext ? "[↑↓] navigate · [Enter] select · [Tab] add context · [Esc] cancel" : undefined); + })(); + return ( - {items.map((item, i) => ( - - ))} - {footer ? ( + {items.map((item, i) => { + const showEditing = i === index && isEditing; + const displayLabel = showEditing + ? `${item.label}, ${editingContext}` + : item.label; + return ( + + ); + })} + {resolvedFooter ? ( - {footer} + {resolvedFooter} ) : null} @@ -162,10 +222,12 @@ function SelectRow({ item, active, marker, + showInlineCursor = false, }: { item: SelectItem; active: boolean; marker: string; + showInlineCursor?: boolean; }) { // Color: dim for disabled, primary cyan + bold for active, plain // default for inactive. Keeps the active-row affordance consistent @@ -177,6 +239,11 @@ function SelectRow({ {marker} {item.label} + {showInlineCursor ? ( + + {" "} + + ) : null} {item.hint ? ( diff --git a/src/cli/ui/ShellConfirm.tsx b/src/cli/ui/ShellConfirm.tsx index a3226bd..71b5c82 100644 --- a/src/cli/ui/ShellConfirm.tsx +++ b/src/cli/ui/ShellConfirm.tsx @@ -20,7 +20,7 @@ export interface ShellConfirmProps { * approving will block the TUI or not. */ kind?: "run_command" | "run_background"; - onChoose: (choice: ShellConfirmChoice) => void; + onChoose: (choice: ShellConfirmChoice, denyContext?: string) => void; } /** @@ -33,12 +33,25 @@ export interface ShellConfirmProps { * 3. Deny — tell the model the user refused. * Arrow keys + Enter. No y/n hotkey — too easy to trigger by accident * when the user was mid-typing a response. + * + * The "Deny" item supports inline context: pressing Tab appends `,` + * and lets the user type a reason directly on the selected item. The + * context is returned as the second argument to `onChoose`. */ export function ShellConfirm({ command, allowPrefix, kind, onChoose }: ShellConfirmProps) { const isBackground = kind === "run_background"; const subtitle = isBackground ? "long-running process — keeps running after approval, /kill to stop" : "model wants to run a shell command"; + + // Deny item with inline context support (Tab → `, ` + inline typing) + const denyItem = { + value: "deny" as const, + label: "Deny", + hint: "Not what you wanted? Press Tab to append `,` and tell the model what to do instead.", + denyWithContext: true as const, + }; + return ( onChoose(v as ShellConfirmChoice)} + onSubmit={(v, ctx) => { + if (v === "deny") onChoose("deny", ctx); + else onChoose(v as ShellConfirmChoice); + }} onCancel={() => onChoose("deny")} - footer="↑↓ navigate · ⏎ select · esc deny" + footer="[↑↓] navigate · [Enter] select · [Tab] add context · [Esc] deny" /> ); diff --git a/src/cli/ui/WorkspaceConfirm.tsx b/src/cli/ui/WorkspaceConfirm.tsx index 277575f..a9993ba 100644 --- a/src/cli/ui/WorkspaceConfirm.tsx +++ b/src/cli/ui/WorkspaceConfirm.tsx @@ -15,13 +15,17 @@ export interface WorkspaceConfirmProps { * those won't follow the switch (their child processes were spawned * with the original cwd). 0 means no warning. */ mcpServerCount: number; - onChoose: (choice: WorkspaceConfirmChoice) => void; + onChoose: (choice: WorkspaceConfirmChoice, denyContext?: string) => void; } /** * Modal-style approval for a `change_workspace` tool call. Two * choices, Enter / Esc bindings. No "always allow" — workspace * switches are per-target by nature. + * + * The "Deny" item supports inline context: pressing Tab appends `,` + * and lets the user type a reason directly on the selected item. The + * context is returned as the second argument to `onChoose`. */ export function WorkspaceConfirm({ path, @@ -33,6 +37,7 @@ export function WorkspaceConfirm({ mcpServerCount > 0 ? `MCP servers (${mcpServerCount}) stay anchored to the original launch root.` : "Re-registers filesystem / shell / memory tools at the new path."; + return ( @@ -58,12 +63,13 @@ export function WorkspaceConfirm({ { value: "deny", label: "Deny", - hint: "Tell the model the user refused; it will continue without changing directories.", + hint: "Tell the model why you're refusing; it will continue without changing directories.", + denyWithContext: true, }, ]} - onSubmit={(v) => onChoose(v as WorkspaceConfirmChoice)} + onSubmit={(v, ctx) => onChoose(v as WorkspaceConfirmChoice, ctx)} onCancel={() => onChoose("deny")} - footer="↑↓ navigate · ⏎ select · esc deny" + footer="[↑↓] navigate · [Enter] select · [Tab] add context · [Esc] deny" /> ); diff --git a/src/cli/ui/keystroke-context.tsx b/src/cli/ui/keystroke-context.tsx index f3a8220..df536ab 100644 --- a/src/cli/ui/keystroke-context.tsx +++ b/src/cli/ui/keystroke-context.tsx @@ -22,6 +22,7 @@ // biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value import React, { createContext, useContext, useEffect, useRef } from "react"; +import { useInput } from "ink"; import { type KeyEvent, type StdinReader, getStdinReader } from "./stdin-reader.js"; interface KeystrokeBus { @@ -91,18 +92,56 @@ export function KeystrokeProvider({ * Handler identity changes are tolerated — we re-subscribe via * useEffect on every render. Wrap your handler in `useCallback` if * you want to avoid that. + * + * **Ink fallback:** When no `KeystrokeProvider` is mounted in the + * tree (e.g. the Setup/Wizard screens), this hook falls back to + * Ink v5's built-in `useInput`. This ensures components like + * `SingleSelect` work both inside and outside the KeystrokeProvider. + * Without this fallback, a component using `useKeystroke` outside a + * KeystrokeProvider silently never receives events. */ export function useKeystroke(handler: KeystrokeHandler, isActive = true): void { const bus = useContext(KeystrokeContext); - // Latest-handler ref so we can subscribe ONCE per active toggle - // and still call the freshest closure on each event. + // Latest-handler ref so we can call the freshest closure on every + // event regardless of re-render schedule. const handlerRef = useRef(handler); handlerRef.current = handler; + // Primary: subscribe via KeystrokeBus (our StdinReader + fan-out). useEffect(() => { if (!bus || !isActive) return undefined; return bus.subscribe((ev) => handlerRef.current(ev)); }, [bus, isActive]); + + // Fallback: when no KeystrokeProvider is mounted, use Ink's + // native useInput. The effect and this useInput are both always + // called, but only one actually dispatches based on whether bus + // is present. This avoids the `readable` vs `data` listener + // conflict on stdin. + useInput( + (input, key) => { + if (bus) return; // KeystrokeProvider is active — skip fallback + handlerRef.current({ + input, + upArrow: key.upArrow, + downArrow: key.downArrow, + leftArrow: key.leftArrow, + rightArrow: key.rightArrow, + return: key.return, + escape: key.escape, + backspace: key.backspace, + delete: key.delete, + tab: key.tab, + shift: key.shift, + ctrl: key.ctrl, + meta: key.meta, + pageUp: key.pageUp, + pageDown: key.pageDown, + // Ink v5 Key type doesn't include home/end — omitted + }); + }, + { isActive: !bus && isActive }, + ); } /** diff --git a/src/cli/ui/stdin-reader.ts b/src/cli/ui/stdin-reader.ts index ca86e9f..52d8861 100644 --- a/src/cli/ui/stdin-reader.ts +++ b/src/cli/ui/stdin-reader.ts @@ -212,11 +212,17 @@ export class StdinReader { start(): void { if (this.started) return; - if (!stdin.isTTY) { - // Non-TTY (piped input). We can't run interactively — don't try. + // Try to enter raw mode. In Node.js `process.stdin.isTTY` is `true` + // in TTY environments and `undefined` when piped, but bun leaves + // `isTTY` as `undefined` even in a real terminal. A direct + // `setRawMode` call is the universal check — it throws in + // non-TTY or closed pipes, and succeeds otherwise. + try { + stdin.setRawMode(true); + } catch { + // Not a TTY or not supported — can't run interactively. return; } - stdin.setRawMode(true); stdin.setEncoding("utf8"); stdin.resume(); this.listener = (chunk) => @@ -231,12 +237,10 @@ export class StdinReader { stdin.off("data", this.listener); this.listener = null; } - if (stdin.isTTY) { - try { - stdin.setRawMode(false); - } catch { - // setRawMode may throw if stdin is already closed; ignore. - } + try { + stdin.setRawMode(false); + } catch { + // setRawMode may throw if stdin is already closed; ignore. } stdin.pause(); this.cancelEscTimer(); From 7d9447b199ffe94de4cbc0e76fd78d74cacfe699 Mon Sep 17 00:00:00 2001 From: wviana <1062631+wviana@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:05:37 -0300 Subject: [PATCH 2/2] lint fix --- src/cli/ui/Select.tsx | 13 ++++++++----- src/cli/ui/keystroke-context.tsx | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cli/ui/Select.tsx b/src/cli/ui/Select.tsx index 82b742b..19ea753 100644 --- a/src/cli/ui/Select.tsx +++ b/src/cli/ui/Select.tsx @@ -89,7 +89,7 @@ export function SingleSelect({ setEditingContext((v) => (v ?? "").slice(0, -1)); } else if (ev.tab) { // Tab while editing appends ", " to the context text - setEditingContext((v) => (v ?? "") + ", "); + setEditingContext((v) => `${v ?? ""}, `); } else if (ev.input) { setEditingContext((v) => (v ?? "") + ev.input); } @@ -116,16 +116,19 @@ export function SingleSelect({ const canDenyWithContext = activeItem?.denyWithContext; const resolvedFooter = (() => { if (isEditing) return "[Enter] confirm · [Esc] cancel · [↑↓] change option"; - return footer ?? (canDenyWithContext ? "[↑↓] navigate · [Enter] select · [Tab] add context · [Esc] cancel" : undefined); + return ( + footer ?? + (canDenyWithContext + ? "[↑↓] navigate · [Enter] select · [Tab] add context · [Esc] cancel" + : undefined) + ); })(); return ( {items.map((item, i) => { const showEditing = i === index && isEditing; - const displayLabel = showEditing - ? `${item.label}, ${editingContext}` - : item.label; + const displayLabel = showEditing ? `${item.label}, ${editingContext}` : item.label; return (