Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<EditReviewChoice>((resolveChoice) => {
const { choice, denyContext } = await new Promise<EditReviewResult>((resolveChoice) => {
editReviewResolveRef.current = resolveChoice;
setPendingEditReview(block);
});
Expand All @@ -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";
Expand Down Expand Up @@ -2137,7 +2143,7 @@ export function App({
if (resolve) {
editReviewResolveRef.current = null;
setPendingEditReview(null);
resolve(choice);
resolve({ choice, denyContext: undefined });
}
},
resolveWorkspaceConfirm: (choice) => {
Expand Down Expand Up @@ -3545,19 +3551,20 @@ 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;
setPendingShell(null);

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);
Expand Down Expand Up @@ -3666,23 +3673,24 @@ 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;
setPendingWorkspace(null);

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) => [
Expand Down Expand Up @@ -4451,11 +4459,11 @@ export function App({
) : pendingEditReview ? (
<EditConfirm
block={pendingEditReview}
onChoose={(choice) => {
onChoose={(choice, denyContext) => {
const resolve = editReviewResolveRef.current;
if (resolve) {
editReviewResolveRef.current = null;
resolve(choice);
resolve({ choice, denyContext });
}
}}
/>
Expand Down
90 changes: 90 additions & 0 deletions src/cli/ui/DenyContextInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box flexDirection="column">
<Box>
<Text dimColor>{label} </Text>
<Text>{value}</Text>
{cursorVisible ? (
<Text backgroundColor="#67e8f9" color="black">
{" "}
</Text>
) : null}
</Box>
<Box marginTop={1}>
<Text dimColor>
{"["}
<Text color="#67e8f9" bold>
Enter
</Text>
{"] confirm · ["}
<Text color="#67e8f9" bold>
Esc
</Text>
{"] cancel"}
</Text>
</Box>
</Box>
);
}
93 changes: 65 additions & 28 deletions src/cli/ui/EditConfirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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") {
Expand Down Expand Up @@ -145,39 +165,31 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
`viewing ${effectiveScroll + 1}-${effectiveScroll + visibleRows.length}/${totalLines}`,
);
}
const footer = (
<Text dimColor>
{"["}
<Text color="#67e8f9" bold>
y
</Text>
{"/Enter] apply · ["}
<Text color="#67e8f9" bold>
n
</Text>
{"] reject · ["}
<Text color="#67e8f9" bold>
a
</Text>
{"] apply rest · ["}
<Text color="#67e8f9" bold>
A
</Text>
{"] flip AUTO · ["}
<Text color="#67e8f9" bold>
↑↓/Space
</Text>
{"] scroll · [Esc] abort"}
</Text>
);

// Context-input phase: show DenyContextInput instead of the diff
if (phase === "context") {
return (
<ModalCard
accent={isNew ? "#86efac" : "#fcd34d"}
icon={isNew ? "✚" : "✎"}
title={`${tag} ${block.path}`}
subtitle="rejecting — add context (optional)"
>
<DenyContextInput
label="Reason for rejecting (or Enter to reject without context): "
onSubmit={(context) => onChoose("reject", context)}
onCancel={() => setPhase("review")}
/>
</ModalCard>
);
}

return (
<ModalCard
accent={isNew ? "#86efac" : "#fcd34d"}
icon={isNew ? "✚" : "✎"}
title={`${tag} ${block.path}`}
subtitle={subtitleParts.join(" · ")}
footer={footer}
>
{hiddenAbove > 0 ? (
<Text
Expand All @@ -202,6 +214,31 @@ export function EditConfirm({ block, onChoose }: EditConfirmProps) {
dimColor
>{` ↓ ${hiddenBelow} line${hiddenBelow === 1 ? "" : "s"} below (↓/j or Space/PgDn)`}</Text>
) : null}
<Box marginTop={1}>
<Text dimColor>
{"["}
<Text color="#67e8f9" bold>
y
</Text>
{"/Enter] apply · ["}
<Text color="#67e8f9" bold>
n
</Text>
{"] reject with reason · ["}
<Text color="#67e8f9" bold>
a
</Text>
{"] apply rest · ["}
<Text color="#67e8f9" bold>
A
</Text>
{"] flip AUTO · ["}
<Text color="#67e8f9" bold>
↑↓/Space
</Text>
{"] scroll · [Esc] abort"}
</Text>
</Box>
</ModalCard>
);
}
Loading
Loading