396
src/cli/App.tsx
396
src/cli/App.tsx
@@ -54,15 +54,21 @@ import {
|
|||||||
validateProfileLoad,
|
validateProfileLoad,
|
||||||
} from "./commands/profile";
|
} from "./commands/profile";
|
||||||
import { AgentSelector } from "./components/AgentSelector";
|
import { AgentSelector } from "./components/AgentSelector";
|
||||||
import { ApprovalDialog } from "./components/ApprovalDialogRich";
|
// ApprovalDialog removed - all approvals now render inline
|
||||||
import { AssistantMessage } from "./components/AssistantMessageRich";
|
import { AssistantMessage } from "./components/AssistantMessageRich";
|
||||||
import { BashCommandMessage } from "./components/BashCommandMessage";
|
import { BashCommandMessage } from "./components/BashCommandMessage";
|
||||||
import { CommandMessage } from "./components/CommandMessage";
|
import { CommandMessage } from "./components/CommandMessage";
|
||||||
import { colors } from "./components/colors";
|
import { colors } from "./components/colors";
|
||||||
import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog";
|
// EnterPlanModeDialog removed - now using InlineEnterPlanModeApproval
|
||||||
import { ErrorMessage } from "./components/ErrorMessageRich";
|
import { ErrorMessage } from "./components/ErrorMessageRich";
|
||||||
import { FeedbackDialog } from "./components/FeedbackDialog";
|
import { FeedbackDialog } from "./components/FeedbackDialog";
|
||||||
import { HelpDialog } from "./components/HelpDialog";
|
import { HelpDialog } from "./components/HelpDialog";
|
||||||
|
import { InlineBashApproval } from "./components/InlineBashApproval";
|
||||||
|
import { InlineEnterPlanModeApproval } from "./components/InlineEnterPlanModeApproval";
|
||||||
|
import { InlineFileEditApproval } from "./components/InlineFileEditApproval";
|
||||||
|
import { InlineGenericApproval } from "./components/InlineGenericApproval";
|
||||||
|
import { InlinePlanApproval } from "./components/InlinePlanApproval";
|
||||||
|
import { InlineQuestionApproval } from "./components/InlineQuestionApproval";
|
||||||
import { Input } from "./components/InputRich";
|
import { Input } from "./components/InputRich";
|
||||||
import { McpSelector } from "./components/McpSelector";
|
import { McpSelector } from "./components/McpSelector";
|
||||||
import { MemoryViewer } from "./components/MemoryViewer";
|
import { MemoryViewer } from "./components/MemoryViewer";
|
||||||
@@ -71,8 +77,7 @@ import { ModelSelector } from "./components/ModelSelector";
|
|||||||
import { NewAgentDialog } from "./components/NewAgentDialog";
|
import { NewAgentDialog } from "./components/NewAgentDialog";
|
||||||
import { OAuthCodeDialog } from "./components/OAuthCodeDialog";
|
import { OAuthCodeDialog } from "./components/OAuthCodeDialog";
|
||||||
import { PinDialog, validateAgentName } from "./components/PinDialog";
|
import { PinDialog, validateAgentName } from "./components/PinDialog";
|
||||||
import { PlanModeDialog } from "./components/PlanModeDialog";
|
// QuestionDialog removed - now using InlineQuestionApproval
|
||||||
import { QuestionDialog } from "./components/QuestionDialog";
|
|
||||||
import { ReasoningMessage } from "./components/ReasoningMessageRich";
|
import { ReasoningMessage } from "./components/ReasoningMessageRich";
|
||||||
import { ResumeSelector } from "./components/ResumeSelector";
|
import { ResumeSelector } from "./components/ResumeSelector";
|
||||||
import { formatUsageStats } from "./components/SessionStats";
|
import { formatUsageStats } from "./components/SessionStats";
|
||||||
@@ -127,8 +132,13 @@ import {
|
|||||||
isFileEditTool,
|
isFileEditTool,
|
||||||
isFileWriteTool,
|
isFileWriteTool,
|
||||||
isPatchTool,
|
isPatchTool,
|
||||||
|
isShellTool,
|
||||||
} from "./helpers/toolNameMapping";
|
} from "./helpers/toolNameMapping";
|
||||||
import { isFancyUITool, isTaskTool } from "./helpers/toolNameMapping.js";
|
import {
|
||||||
|
alwaysRequiresUserInput,
|
||||||
|
isFancyUITool,
|
||||||
|
isTaskTool,
|
||||||
|
} from "./helpers/toolNameMapping.js";
|
||||||
import { useSuspend } from "./hooks/useSuspend/useSuspend.ts";
|
import { useSuspend } from "./hooks/useSuspend/useSuspend.ts";
|
||||||
import { useSyncedState } from "./hooks/useSyncedState";
|
import { useSyncedState } from "./hooks/useSyncedState";
|
||||||
import { useTerminalWidth } from "./hooks/useTerminalWidth";
|
import { useTerminalWidth } from "./hooks/useTerminalWidth";
|
||||||
@@ -505,6 +515,7 @@ export default function App({
|
|||||||
// Derive current approval from pending approvals and results
|
// Derive current approval from pending approvals and results
|
||||||
// This is the approval currently being shown to the user
|
// This is the approval currently being shown to the user
|
||||||
const currentApproval = pendingApprovals[approvalResults.length];
|
const currentApproval = pendingApprovals[approvalResults.length];
|
||||||
|
const currentApprovalContext = approvalContexts[approvalResults.length];
|
||||||
|
|
||||||
// Overlay/selector state - only one can be open at a time
|
// Overlay/selector state - only one can be open at a time
|
||||||
type ActiveOverlay =
|
type ActiveOverlay =
|
||||||
@@ -778,7 +789,11 @@ export default function App({
|
|||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Recompute UI state from buffers after chunks (micro-batched)
|
// Store the last plan file path for post-approval rendering
|
||||||
|
// (needed because plan mode is exited before rendering the result)
|
||||||
|
const lastPlanFilePathRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Recompute UI state from buffers after each streaming chunk
|
||||||
const refreshDerived = useCallback(() => {
|
const refreshDerived = useCallback(() => {
|
||||||
const b = buffersRef.current;
|
const b = buffersRef.current;
|
||||||
setTokenCount(b.tokenCount);
|
setTokenCount(b.tokenCount);
|
||||||
@@ -1348,9 +1363,11 @@ export default function App({
|
|||||||
const { approval, permission } = ac;
|
const { approval, permission } = ac;
|
||||||
let decision = permission.decision;
|
let decision = permission.decision;
|
||||||
|
|
||||||
// Fancy tools should always go through a UI dialog in interactive mode,
|
// Some tools always need user input regardless of yolo mode
|
||||||
// even if a rule says "allow". Deny rules are still respected.
|
if (
|
||||||
if (isFancyUITool(approval.toolName) && decision === "allow") {
|
alwaysRequiresUserInput(approval.toolName) &&
|
||||||
|
decision === "allow"
|
||||||
|
) {
|
||||||
decision = "ask";
|
decision = "ask";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,8 +1381,9 @@ export default function App({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Precompute diffs for auto-allowed file edit tools before execution
|
// Precompute diffs for file edit tools before execution (both auto-allowed and needs-user-input)
|
||||||
for (const ac of autoAllowed) {
|
// This is needed for inline approval UI to show diffs, and for post-approval rendering
|
||||||
|
for (const ac of [...autoAllowed, ...needsUserInput]) {
|
||||||
const toolName = ac.approval.toolName;
|
const toolName = ac.approval.toolName;
|
||||||
const toolCallId = ac.approval.toolCallId;
|
const toolCallId = ac.approval.toolCallId;
|
||||||
try {
|
try {
|
||||||
@@ -1893,11 +1911,6 @@ export default function App({
|
|||||||
setAutoHandledResults([]);
|
setAutoHandledResults([]);
|
||||||
setAutoDeniedApprovals([]);
|
setAutoDeniedApprovals([]);
|
||||||
|
|
||||||
// Force clean re-render to avoid streaking artifacts
|
|
||||||
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
|
|
||||||
// The epoch increment alone should reset Ink's line tracking
|
|
||||||
setStaticRenderEpoch((e) => e + 1);
|
|
||||||
|
|
||||||
// Send cancel request to backend asynchronously (fire-and-forget)
|
// Send cancel request to backend asynchronously (fire-and-forget)
|
||||||
// Don't wait for it or show errors since user already got feedback
|
// Don't wait for it or show errors since user already got feedback
|
||||||
getClient()
|
getClient()
|
||||||
@@ -3896,8 +3909,11 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
|||||||
const { approval, permission } = ac;
|
const { approval, permission } = ac;
|
||||||
let decision = permission.decision;
|
let decision = permission.decision;
|
||||||
|
|
||||||
// Fancy tools always need user input (except if denied)
|
// Some tools always need user input regardless of yolo mode
|
||||||
if (isFancyUITool(approval.toolName) && decision === "allow") {
|
if (
|
||||||
|
alwaysRequiresUserInput(approval.toolName) &&
|
||||||
|
decision === "allow"
|
||||||
|
) {
|
||||||
decision = "ask";
|
decision = "ask";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3912,8 +3928,8 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
|||||||
|
|
||||||
// If all approvals can be auto-handled (yolo mode), process them immediately
|
// If all approvals can be auto-handled (yolo mode), process them immediately
|
||||||
if (needsUserInput.length === 0) {
|
if (needsUserInput.length === 0) {
|
||||||
// Precompute diffs for auto-allowed file edit tools before execution
|
// Precompute diffs for file edit tools before execution (both auto-allowed and needs-user-input)
|
||||||
for (const ac of autoAllowed) {
|
for (const ac of [...autoAllowed, ...needsUserInput]) {
|
||||||
const toolName = ac.approval.toolName;
|
const toolName = ac.approval.toolName;
|
||||||
const toolCallId = ac.approval.toolCallId;
|
const toolCallId = ac.approval.toolCallId;
|
||||||
try {
|
try {
|
||||||
@@ -4068,8 +4084,8 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
|||||||
.filter(Boolean) as ApprovalContext[],
|
.filter(Boolean) as ApprovalContext[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Precompute diffs for auto-allowed file edit tools before execution
|
// Precompute diffs for file edit tools before execution (both auto-allowed and needs-user-input)
|
||||||
for (const ac of autoAllowed) {
|
for (const ac of [...autoAllowed, ...needsUserInput]) {
|
||||||
const toolName = ac.approval.toolName;
|
const toolName = ac.approval.toolName;
|
||||||
const toolCallId = ac.approval.toolCallId;
|
const toolCallId = ac.approval.toolCallId;
|
||||||
try {
|
try {
|
||||||
@@ -4287,9 +4303,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
|||||||
setApprovalResults([]);
|
setApprovalResults([]);
|
||||||
setAutoHandledResults([]);
|
setAutoHandledResults([]);
|
||||||
setAutoDeniedApprovals([]);
|
setAutoDeniedApprovals([]);
|
||||||
// Force clean re-render to avoid streaking artifacts
|
|
||||||
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
|
|
||||||
setStaticRenderEpoch((e) => e + 1);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4306,11 +4319,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
|||||||
setAutoHandledResults([]);
|
setAutoHandledResults([]);
|
||||||
setAutoDeniedApprovals([]);
|
setAutoDeniedApprovals([]);
|
||||||
|
|
||||||
// Force clean re-render to avoid streaking artifacts
|
|
||||||
// The large approval dialog disappearing causes line count mismatch in Ink
|
|
||||||
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
|
|
||||||
setStaticRenderEpoch((e) => e + 1);
|
|
||||||
|
|
||||||
// Show "thinking" state and lock input while executing approved tools client-side
|
// Show "thinking" state and lock input while executing approved tools client-side
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
// Ensure interrupted flag is cleared for this execution
|
// Ensure interrupted flag is cleared for this execution
|
||||||
@@ -4583,9 +4591,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
|||||||
setAutoHandledResults([]);
|
setAutoHandledResults([]);
|
||||||
setAutoDeniedApprovals([]);
|
setAutoDeniedApprovals([]);
|
||||||
|
|
||||||
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
|
|
||||||
setStaticRenderEpoch((e) => e + 1);
|
|
||||||
|
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
buffersRef.current.interrupted = false;
|
buffersRef.current.interrupted = false;
|
||||||
|
|
||||||
@@ -4706,10 +4711,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
|||||||
setApprovalResults([]);
|
setApprovalResults([]);
|
||||||
setAutoHandledResults([]);
|
setAutoHandledResults([]);
|
||||||
setAutoDeniedApprovals([]);
|
setAutoDeniedApprovals([]);
|
||||||
|
|
||||||
// Force clean re-render to avoid streaking artifacts
|
|
||||||
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
|
|
||||||
setStaticRenderEpoch((e) => e + 1);
|
|
||||||
}, [pendingApprovals, refreshDerived]);
|
}, [pendingApprovals, refreshDerived]);
|
||||||
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
@@ -5122,6 +5123,10 @@ DO NOT respond to these messages or otherwise consider them in your response unl
|
|||||||
|
|
||||||
const isLast = currentIndex + 1 >= pendingApprovals.length;
|
const isLast = currentIndex + 1 >= pendingApprovals.length;
|
||||||
|
|
||||||
|
// Capture plan file path BEFORE exiting plan mode (for post-approval rendering)
|
||||||
|
const planFilePath = permissionMode.getPlanFilePath();
|
||||||
|
lastPlanFilePathRef.current = planFilePath;
|
||||||
|
|
||||||
// Exit plan mode
|
// Exit plan mode
|
||||||
const newMode = acceptEdits ? "acceptEdits" : "default";
|
const newMode = acceptEdits ? "acceptEdits" : "default";
|
||||||
permissionMode.setMode(newMode);
|
permissionMode.setMode(newMode);
|
||||||
@@ -5494,6 +5499,7 @@ Plan file path: ${planFilePath}`;
|
|||||||
<ToolCallMessage
|
<ToolCallMessage
|
||||||
line={item}
|
line={item}
|
||||||
precomputedDiffs={precomputedDiffsRef.current}
|
precomputedDiffs={precomputedDiffsRef.current}
|
||||||
|
lastPlanFilePath={lastPlanFilePathRef.current}
|
||||||
/>
|
/>
|
||||||
) : item.kind === "subagent_group" ? (
|
) : item.kind === "subagent_group" ? (
|
||||||
<SubagentGroupStatic agents={item.agents} />
|
<SubagentGroupStatic agents={item.agents} />
|
||||||
@@ -5525,32 +5531,242 @@ Plan file path: ${planFilePath}`;
|
|||||||
{loadingState === "ready" && (
|
{loadingState === "ready" && (
|
||||||
<>
|
<>
|
||||||
{/* Transcript */}
|
{/* Transcript */}
|
||||||
{liveItems.length > 0 && pendingApprovals.length === 0 && (
|
{/* Show liveItems always - all approvals now render inline */}
|
||||||
|
{liveItems.length > 0 && (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{liveItems.map((ln) => (
|
{liveItems.map((ln) => {
|
||||||
<Box key={ln.id} marginTop={1}>
|
// Check if this tool call matches the current ExitPlanMode approval
|
||||||
{ln.kind === "user" ? (
|
const isExitPlanModeApproval =
|
||||||
<UserMessage line={ln} />
|
ln.kind === "tool_call" &&
|
||||||
) : ln.kind === "reasoning" ? (
|
currentApproval?.toolName === "ExitPlanMode" &&
|
||||||
<ReasoningMessage line={ln} />
|
ln.toolCallId === currentApproval?.toolCallId;
|
||||||
) : ln.kind === "assistant" ? (
|
|
||||||
<AssistantMessage line={ln} />
|
// Check if this tool call matches a file edit/write/patch approval
|
||||||
) : ln.kind === "tool_call" ? (
|
const isFileEditApproval =
|
||||||
<ToolCallMessage
|
ln.kind === "tool_call" &&
|
||||||
line={ln}
|
currentApproval &&
|
||||||
precomputedDiffs={precomputedDiffsRef.current}
|
(isFileEditTool(currentApproval.toolName) ||
|
||||||
/>
|
isFileWriteTool(currentApproval.toolName) ||
|
||||||
) : ln.kind === "error" ? (
|
isPatchTool(currentApproval.toolName)) &&
|
||||||
<ErrorMessage line={ln} />
|
ln.toolCallId === currentApproval.toolCallId;
|
||||||
) : ln.kind === "status" ? (
|
|
||||||
<StatusMessage line={ln} />
|
// Check if this tool call matches a bash/shell approval
|
||||||
) : ln.kind === "command" ? (
|
const isBashApproval =
|
||||||
<CommandMessage line={ln} />
|
ln.kind === "tool_call" &&
|
||||||
) : ln.kind === "bash_command" ? (
|
currentApproval &&
|
||||||
<BashCommandMessage line={ln} />
|
isShellTool(currentApproval.toolName) &&
|
||||||
) : null}
|
ln.toolCallId === currentApproval.toolCallId;
|
||||||
</Box>
|
|
||||||
))}
|
// Check if this tool call matches an EnterPlanMode approval
|
||||||
|
const isEnterPlanModeApproval =
|
||||||
|
ln.kind === "tool_call" &&
|
||||||
|
currentApproval?.toolName === "EnterPlanMode" &&
|
||||||
|
ln.toolCallId === currentApproval?.toolCallId;
|
||||||
|
|
||||||
|
// Check if this tool call matches an AskUserQuestion approval
|
||||||
|
const isAskUserQuestionApproval =
|
||||||
|
ln.kind === "tool_call" &&
|
||||||
|
currentApproval?.toolName === "AskUserQuestion" &&
|
||||||
|
ln.toolCallId === currentApproval?.toolCallId;
|
||||||
|
|
||||||
|
// Parse file edit info from approval args
|
||||||
|
const getFileEditInfo = () => {
|
||||||
|
if (!isFileEditApproval || !currentApproval) return null;
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(currentApproval.toolArgs || "{}");
|
||||||
|
|
||||||
|
// For patch tools, use the input field
|
||||||
|
if (isPatchTool(currentApproval.toolName)) {
|
||||||
|
return {
|
||||||
|
toolName: currentApproval.toolName,
|
||||||
|
filePath: "", // Patch can have multiple files
|
||||||
|
patchInput: args.input as string | undefined,
|
||||||
|
toolCallId: ln.toolCallId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular file edit/write tools
|
||||||
|
return {
|
||||||
|
toolName: currentApproval.toolName,
|
||||||
|
filePath: String(args.file_path || ""),
|
||||||
|
content: args.content as string | undefined,
|
||||||
|
oldString: args.old_string as string | undefined,
|
||||||
|
newString: args.new_string as string | undefined,
|
||||||
|
replaceAll: args.replace_all as boolean | undefined,
|
||||||
|
edits: args.edits as
|
||||||
|
| Array<{
|
||||||
|
old_string: string;
|
||||||
|
new_string: string;
|
||||||
|
replace_all?: boolean;
|
||||||
|
}>
|
||||||
|
| undefined,
|
||||||
|
toolCallId: ln.toolCallId,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileEditInfo = getFileEditInfo();
|
||||||
|
|
||||||
|
// Parse bash info from approval args
|
||||||
|
const getBashInfo = () => {
|
||||||
|
if (!isBashApproval || !currentApproval) return null;
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(currentApproval.toolArgs || "{}");
|
||||||
|
const t = currentApproval.toolName.toLowerCase();
|
||||||
|
|
||||||
|
// Handle different bash tool arg formats
|
||||||
|
let command = "";
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
if (t === "shell") {
|
||||||
|
// Shell tool uses command array and justification
|
||||||
|
const cmdVal = args.command;
|
||||||
|
command = Array.isArray(cmdVal)
|
||||||
|
? cmdVal.join(" ")
|
||||||
|
: typeof cmdVal === "string"
|
||||||
|
? cmdVal
|
||||||
|
: "(no command)";
|
||||||
|
description =
|
||||||
|
typeof args.justification === "string"
|
||||||
|
? args.justification
|
||||||
|
: "";
|
||||||
|
} else {
|
||||||
|
// Bash/shell_command uses command string and description
|
||||||
|
command =
|
||||||
|
typeof args.command === "string"
|
||||||
|
? args.command
|
||||||
|
: "(no command)";
|
||||||
|
description =
|
||||||
|
typeof args.description === "string"
|
||||||
|
? args.description
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolName: currentApproval.toolName,
|
||||||
|
command,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bashInfo = getBashInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={ln.id} flexDirection="column" marginTop={1}>
|
||||||
|
{/* For ExitPlanMode awaiting approval: render InlinePlanApproval */}
|
||||||
|
{isExitPlanModeApproval ? (
|
||||||
|
<InlinePlanApproval
|
||||||
|
plan={readPlanFile()}
|
||||||
|
onApprove={() => handlePlanApprove(false)}
|
||||||
|
onApproveAndAcceptEdits={() =>
|
||||||
|
handlePlanApprove(true)
|
||||||
|
}
|
||||||
|
onKeepPlanning={handlePlanKeepPlanning}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
) : isFileEditApproval && fileEditInfo ? (
|
||||||
|
<InlineFileEditApproval
|
||||||
|
fileEdit={fileEditInfo}
|
||||||
|
precomputedDiff={
|
||||||
|
ln.toolCallId
|
||||||
|
? precomputedDiffsRef.current.get(ln.toolCallId)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
allDiffs={precomputedDiffsRef.current}
|
||||||
|
onApprove={(diffs) => handleApproveCurrent(diffs)}
|
||||||
|
onApproveAlways={(scope, diffs) =>
|
||||||
|
handleApproveAlways(scope, diffs)
|
||||||
|
}
|
||||||
|
onDeny={(reason) => handleDenyCurrent(reason)}
|
||||||
|
onCancel={handleCancelApprovals}
|
||||||
|
isFocused={true}
|
||||||
|
approveAlwaysText={
|
||||||
|
currentApprovalContext?.approveAlwaysText
|
||||||
|
}
|
||||||
|
allowPersistence={
|
||||||
|
currentApprovalContext?.allowPersistence ?? true
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : isBashApproval && bashInfo ? (
|
||||||
|
<InlineBashApproval
|
||||||
|
bashInfo={bashInfo}
|
||||||
|
onApprove={() => handleApproveCurrent()}
|
||||||
|
onApproveAlways={(scope) =>
|
||||||
|
handleApproveAlways(scope)
|
||||||
|
}
|
||||||
|
onDeny={(reason) => handleDenyCurrent(reason)}
|
||||||
|
onCancel={handleCancelApprovals}
|
||||||
|
isFocused={true}
|
||||||
|
approveAlwaysText={
|
||||||
|
currentApprovalContext?.approveAlwaysText
|
||||||
|
}
|
||||||
|
allowPersistence={
|
||||||
|
currentApprovalContext?.allowPersistence ?? true
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : isEnterPlanModeApproval ? (
|
||||||
|
<InlineEnterPlanModeApproval
|
||||||
|
onApprove={handleEnterPlanModeApprove}
|
||||||
|
onReject={handleEnterPlanModeReject}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
) : isAskUserQuestionApproval ? (
|
||||||
|
<InlineQuestionApproval
|
||||||
|
questions={getQuestionsFromApproval(currentApproval)}
|
||||||
|
onSubmit={handleQuestionSubmit}
|
||||||
|
onCancel={handleCancelApprovals}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
) : ln.kind === "tool_call" &&
|
||||||
|
currentApproval &&
|
||||||
|
ln.toolCallId === currentApproval.toolCallId ? (
|
||||||
|
// Generic fallback for any other tool needing approval
|
||||||
|
<InlineGenericApproval
|
||||||
|
toolName={currentApproval.toolName}
|
||||||
|
toolArgs={currentApproval.toolArgs}
|
||||||
|
onApprove={() => handleApproveCurrent()}
|
||||||
|
onApproveAlways={(scope) =>
|
||||||
|
handleApproveAlways(scope)
|
||||||
|
}
|
||||||
|
onDeny={(reason) => handleDenyCurrent(reason)}
|
||||||
|
onCancel={handleCancelApprovals}
|
||||||
|
isFocused={true}
|
||||||
|
approveAlwaysText={
|
||||||
|
currentApprovalContext?.approveAlwaysText
|
||||||
|
}
|
||||||
|
allowPersistence={
|
||||||
|
currentApprovalContext?.allowPersistence ?? true
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : ln.kind === "user" ? (
|
||||||
|
<UserMessage line={ln} />
|
||||||
|
) : ln.kind === "reasoning" ? (
|
||||||
|
<ReasoningMessage line={ln} />
|
||||||
|
) : ln.kind === "assistant" ? (
|
||||||
|
<AssistantMessage line={ln} />
|
||||||
|
) : ln.kind === "tool_call" ? (
|
||||||
|
<ToolCallMessage
|
||||||
|
line={ln}
|
||||||
|
precomputedDiffs={precomputedDiffsRef.current}
|
||||||
|
lastPlanFilePath={lastPlanFilePathRef.current}
|
||||||
|
/>
|
||||||
|
) : ln.kind === "error" ? (
|
||||||
|
<ErrorMessage line={ln} />
|
||||||
|
) : ln.kind === "status" ? (
|
||||||
|
<StatusMessage line={ln} />
|
||||||
|
) : ln.kind === "command" ? (
|
||||||
|
<CommandMessage line={ln} />
|
||||||
|
) : ln.kind === "bash_command" ? (
|
||||||
|
<BashCommandMessage line={ln} />
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -5823,58 +6039,12 @@ Plan file path: ${planFilePath}`;
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Plan Mode Dialog - for ExitPlanMode tool */}
|
{/* Plan Mode Dialog - NOW RENDERED INLINE with tool call (see liveItems above) */}
|
||||||
{currentApproval?.toolName === "ExitPlanMode" && (
|
{/* ExitPlanMode approval is handled by InlinePlanApproval component */}
|
||||||
<PlanModeDialog
|
|
||||||
plan={readPlanFile()}
|
|
||||||
onApprove={() => handlePlanApprove(false)}
|
|
||||||
onApproveAndAcceptEdits={() => handlePlanApprove(true)}
|
|
||||||
onKeepPlanning={handlePlanKeepPlanning}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Question Dialog - for AskUserQuestion tool */}
|
{/* AskUserQuestion now rendered inline via InlineQuestionApproval */}
|
||||||
{currentApproval?.toolName === "AskUserQuestion" && (
|
{/* EnterPlanMode now rendered inline in liveItems above */}
|
||||||
<QuestionDialog
|
{/* ApprovalDialog removed - all approvals now render inline via InlineGenericApproval fallback */}
|
||||||
questions={getQuestionsFromApproval(currentApproval)}
|
|
||||||
onSubmit={handleQuestionSubmit}
|
|
||||||
onCancel={handleCancelApprovals}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Enter Plan Mode Dialog - for EnterPlanMode tool */}
|
|
||||||
{currentApproval?.toolName === "EnterPlanMode" && (
|
|
||||||
<EnterPlanModeDialog
|
|
||||||
onApprove={handleEnterPlanModeApprove}
|
|
||||||
onReject={handleEnterPlanModeReject}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Approval Dialog - for standard tools (not fancy UI tools) */}
|
|
||||||
{currentApproval && !isFancyUITool(currentApproval.toolName) && (
|
|
||||||
<ApprovalDialog
|
|
||||||
approvals={[currentApproval]}
|
|
||||||
approvalContexts={
|
|
||||||
approvalContexts[approvalResults.length]
|
|
||||||
? [
|
|
||||||
approvalContexts[
|
|
||||||
approvalResults.length
|
|
||||||
] as ApprovalContext,
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
progress={{
|
|
||||||
current: approvalResults.length + 1,
|
|
||||||
total: pendingApprovals.length,
|
|
||||||
}}
|
|
||||||
totalTools={autoHandledResults.length + pendingApprovals.length}
|
|
||||||
isExecuting={isExecutingTool}
|
|
||||||
onApproveAll={handleApproveCurrent}
|
|
||||||
onApproveAlways={handleApproveAlways}
|
|
||||||
onDenyAll={handleDenyCurrent}
|
|
||||||
onCancel={handleCancelApprovals}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ export const EnterPlanModeDialog = memo(({ onApprove, onReject }: Props) => {
|
|||||||
|
|
||||||
<Box marginBottom={1} flexDirection="column">
|
<Box marginBottom={1} flexDirection="column">
|
||||||
<Text>
|
<Text>
|
||||||
Letta wants to enter plan mode to explore and design an implementation
|
Letta Code wants to enter plan mode to explore and design an
|
||||||
approach.
|
implementation approach.
|
||||||
</Text>
|
</Text>
|
||||||
<Text> </Text>
|
<Text> </Text>
|
||||||
<Text>In plan mode, Letta will:</Text>
|
<Text>In plan mode, Letta Code will:</Text>
|
||||||
<Text> • Explore the codebase thoroughly</Text>
|
<Text> • Explore the codebase thoroughly</Text>
|
||||||
<Text> • Identify existing patterns</Text>
|
<Text> • Identify existing patterns</Text>
|
||||||
<Text> • Design an implementation strategy</Text>
|
<Text> • Design an implementation strategy</Text>
|
||||||
|
|||||||
234
src/cli/components/InlineBashApproval.tsx
Normal file
234
src/cli/components/InlineBashApproval.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
|
import { colors } from "./colors";
|
||||||
|
|
||||||
|
type BashInfo = {
|
||||||
|
toolName: string;
|
||||||
|
command: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bashInfo: BashInfo;
|
||||||
|
onApprove: () => void;
|
||||||
|
onApproveAlways: (scope: "project" | "session") => void;
|
||||||
|
onDeny: (reason: string) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
isFocused?: boolean;
|
||||||
|
approveAlwaysText?: string;
|
||||||
|
allowPersistence?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal line character for Claude Code style
|
||||||
|
const SOLID_LINE = "─";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InlineBashApproval - Renders bash/shell approval UI inline (Claude Code style)
|
||||||
|
*
|
||||||
|
* Option 3 is an inline text input - when selected, user can type directly
|
||||||
|
* without switching to a separate screen.
|
||||||
|
*/
|
||||||
|
export const InlineBashApproval = memo(
|
||||||
|
({
|
||||||
|
bashInfo,
|
||||||
|
onApprove,
|
||||||
|
onApproveAlways,
|
||||||
|
onDeny,
|
||||||
|
onCancel,
|
||||||
|
isFocused = true,
|
||||||
|
approveAlwaysText,
|
||||||
|
allowPersistence = true,
|
||||||
|
}: Props) => {
|
||||||
|
const [selectedOption, setSelectedOption] = useState(0);
|
||||||
|
const [customReason, setCustomReason] = useState("");
|
||||||
|
const columns = useTerminalWidth();
|
||||||
|
|
||||||
|
// Custom option index depends on whether "always" option is shown
|
||||||
|
const customOptionIndex = allowPersistence ? 2 : 1;
|
||||||
|
const maxOptionIndex = customOptionIndex;
|
||||||
|
const isOnCustomOption = selectedOption === customOptionIndex;
|
||||||
|
const customOptionPlaceholder =
|
||||||
|
"No, and tell Letta Code what to do differently";
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (!isFocused) return;
|
||||||
|
|
||||||
|
// CTRL-C: cancel (queue denial, return to input)
|
||||||
|
if (key.ctrl && input === "c") {
|
||||||
|
onCancel?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow navigation always works
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedOption((prev) => Math.min(maxOptionIndex, prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on custom input option
|
||||||
|
if (isOnCustomOption) {
|
||||||
|
if (key.return) {
|
||||||
|
if (customReason.trim()) {
|
||||||
|
// User typed a reason - send it
|
||||||
|
onDeny(customReason.trim());
|
||||||
|
}
|
||||||
|
// If empty, do nothing (can't submit empty reason)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
if (customReason) {
|
||||||
|
// Clear text first
|
||||||
|
setCustomReason("");
|
||||||
|
} else {
|
||||||
|
// No text, cancel (queue denial, return to input)
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.backspace || key.delete) {
|
||||||
|
setCustomReason((prev) => prev.slice(0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Printable characters - append to custom reason
|
||||||
|
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||||
|
setCustomReason((prev) => prev + input);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on regular options
|
||||||
|
if (key.return) {
|
||||||
|
if (selectedOption === 0) {
|
||||||
|
onApprove();
|
||||||
|
} else if (selectedOption === 1 && allowPersistence) {
|
||||||
|
onApproveAlways("project");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
// Cancel (queue denial, return to input)
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: isFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||||
|
|
||||||
|
// Hint text based on state
|
||||||
|
const hintText = isOnCustomOption
|
||||||
|
? customReason
|
||||||
|
? "Enter to submit · Esc to clear"
|
||||||
|
: "Type reason · Esc to cancel"
|
||||||
|
: "Enter to select · Esc to cancel";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Top solid line */}
|
||||||
|
<Text dimColor>{solidLine}</Text>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Text bold color={colors.approval.header}>
|
||||||
|
Run this command?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Command preview */}
|
||||||
|
<Box paddingLeft={2} flexDirection="column">
|
||||||
|
<Text>{bashInfo.command}</Text>
|
||||||
|
{bashInfo.description && <Text dimColor>{bashInfo.description}</Text>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{/* Option 1: Yes */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedOption === 0 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOption === 0 ? "❯" : " "} 1.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
<Text
|
||||||
|
wrap="wrap"
|
||||||
|
color={
|
||||||
|
selectedOption === 0 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Option 2: Yes, always (only if persistence allowed) */}
|
||||||
|
{allowPersistence && (
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedOption === 1 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOption === 1 ? "❯" : " "} 2.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
<Text
|
||||||
|
wrap="wrap"
|
||||||
|
color={
|
||||||
|
selectedOption === 1 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{approveAlwaysText ||
|
||||||
|
"Yes, and don't ask again for this project"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom input option (3 if persistence, 2 if not) */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={isOnCustomOption ? colors.approval.header : undefined}
|
||||||
|
>
|
||||||
|
{isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
{customReason ? (
|
||||||
|
<Text wrap="wrap">
|
||||||
|
{customReason}
|
||||||
|
{isOnCustomOption && "█"}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text wrap="wrap" dimColor>
|
||||||
|
{customOptionPlaceholder}
|
||||||
|
{isOnCustomOption && "█"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text dimColor>{hintText}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InlineBashApproval.displayName = "InlineBashApproval";
|
||||||
131
src/cli/components/InlineEnterPlanModeApproval.tsx
Normal file
131
src/cli/components/InlineEnterPlanModeApproval.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
|
import { colors } from "./colors";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
isFocused?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal line character for Claude Code style
|
||||||
|
const SOLID_LINE = "─";
|
||||||
|
|
||||||
|
const OptionsRenderer = memo(
|
||||||
|
({
|
||||||
|
options,
|
||||||
|
selectedOption,
|
||||||
|
}: {
|
||||||
|
options: Array<{ label: string }>;
|
||||||
|
selectedOption: number;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{options.map((option, index) => {
|
||||||
|
const isSelected = index === selectedOption;
|
||||||
|
const color = isSelected ? colors.approval.header : undefined;
|
||||||
|
return (
|
||||||
|
<Box key={option.label} flexDirection="row">
|
||||||
|
<Text color={color}>
|
||||||
|
{isSelected ? "❯" : " "} {index + 1}. {option.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
OptionsRenderer.displayName = "OptionsRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InlineEnterPlanModeApproval - Renders EnterPlanMode approval UI inline
|
||||||
|
*
|
||||||
|
* Uses horizontal lines instead of boxes for visual styling.
|
||||||
|
*/
|
||||||
|
export const InlineEnterPlanModeApproval = memo(
|
||||||
|
({ onApprove, onReject, isFocused = true }: Props) => {
|
||||||
|
const [selectedOption, setSelectedOption] = useState(0);
|
||||||
|
const columns = useTerminalWidth();
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: "Yes, enter plan mode", action: onApprove },
|
||||||
|
{ label: "No, start implementing now", action: onReject },
|
||||||
|
];
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (!isFocused) return;
|
||||||
|
|
||||||
|
// CTRL-C: immediately reject (cancel)
|
||||||
|
if (key.ctrl && input === "c") {
|
||||||
|
onReject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC: reject (cancel)
|
||||||
|
if (key.escape) {
|
||||||
|
onReject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
setSelectedOption((prev) => Math.min(options.length - 1, prev + 1));
|
||||||
|
} else if (key.return) {
|
||||||
|
options[selectedOption]?.action();
|
||||||
|
} else if (input === "1") {
|
||||||
|
onApprove();
|
||||||
|
} else if (input === "2") {
|
||||||
|
onReject();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: isFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate horizontal line
|
||||||
|
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Top solid line */}
|
||||||
|
<Text dimColor>{solidLine}</Text>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Text bold color={colors.approval.header}>
|
||||||
|
Enter plan mode?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Box flexDirection="column" paddingLeft={2}>
|
||||||
|
<Text>
|
||||||
|
Letta Code wants to enter plan mode to explore and design an
|
||||||
|
implementation approach.
|
||||||
|
</Text>
|
||||||
|
<Box height={1} />
|
||||||
|
<Text>In plan mode, Letta Code will:</Text>
|
||||||
|
<Text> · Explore the codebase thoroughly</Text>
|
||||||
|
<Text> · Identify existing patterns</Text>
|
||||||
|
<Text> · Design an implementation strategy</Text>
|
||||||
|
<Text> · Present a plan for your approval</Text>
|
||||||
|
<Box height={1} />
|
||||||
|
<Text dimColor>
|
||||||
|
No code changes will be made until you approve the plan.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<OptionsRenderer options={options} selectedOption={selectedOption} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InlineEnterPlanModeApproval.displayName = "InlineEnterPlanModeApproval";
|
||||||
483
src/cli/components/InlineFileEditApproval.tsx
Normal file
483
src/cli/components/InlineFileEditApproval.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { memo, useMemo, useState } from "react";
|
||||||
|
import type { AdvancedDiffSuccess } from "../helpers/diff";
|
||||||
|
import { parsePatchToAdvancedDiff } from "../helpers/diff";
|
||||||
|
import { parsePatchOperations } from "../helpers/formatArgsDisplay";
|
||||||
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
|
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||||||
|
import { colors } from "./colors";
|
||||||
|
|
||||||
|
type FileEditInfo = {
|
||||||
|
toolName: string;
|
||||||
|
filePath: string;
|
||||||
|
// For write tools
|
||||||
|
content?: string;
|
||||||
|
// For edit tools
|
||||||
|
oldString?: string;
|
||||||
|
newString?: string;
|
||||||
|
replaceAll?: boolean;
|
||||||
|
// For multi_edit tools
|
||||||
|
edits?: Array<{
|
||||||
|
old_string: string;
|
||||||
|
new_string: string;
|
||||||
|
replace_all?: boolean;
|
||||||
|
}>;
|
||||||
|
// For patch tools
|
||||||
|
patchInput?: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
fileEdit: FileEditInfo;
|
||||||
|
precomputedDiff?: AdvancedDiffSuccess;
|
||||||
|
allDiffs?: Map<string, AdvancedDiffSuccess>; // For patch tools with multiple files
|
||||||
|
onApprove: (diffs?: Map<string, AdvancedDiffSuccess>) => void;
|
||||||
|
onApproveAlways: (
|
||||||
|
scope: "project" | "session",
|
||||||
|
diffs?: Map<string, AdvancedDiffSuccess>,
|
||||||
|
) => void;
|
||||||
|
onDeny: (reason: string) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
isFocused?: boolean;
|
||||||
|
approveAlwaysText?: string;
|
||||||
|
allowPersistence?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal line characters for Claude Code style
|
||||||
|
const SOLID_LINE = "─";
|
||||||
|
const DOTTED_LINE = "╌";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable header for the file edit
|
||||||
|
*/
|
||||||
|
function getHeaderText(fileEdit: FileEditInfo): string {
|
||||||
|
const t = fileEdit.toolName.toLowerCase();
|
||||||
|
|
||||||
|
// Handle patch tools (multi-file)
|
||||||
|
if (t === "apply_patch" || t === "applypatch") {
|
||||||
|
if (fileEdit.patchInput) {
|
||||||
|
const operations = parsePatchOperations(fileEdit.patchInput);
|
||||||
|
if (operations.length > 1) {
|
||||||
|
return `Apply patch to ${operations.length} files?`;
|
||||||
|
} else if (operations.length === 1) {
|
||||||
|
const op = operations[0];
|
||||||
|
if (op) {
|
||||||
|
const { relative } = require("node:path");
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const relPath = relative(cwd, op.path);
|
||||||
|
const displayPath = relPath.startsWith("..") ? op.path : relPath;
|
||||||
|
|
||||||
|
if (op.kind === "add") {
|
||||||
|
return `Write to ${displayPath}?`;
|
||||||
|
} else if (op.kind === "update") {
|
||||||
|
return `Update ${displayPath}?`;
|
||||||
|
} else if (op.kind === "delete") {
|
||||||
|
return `Delete ${displayPath}?`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Apply patch?";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single-file edit/write tools
|
||||||
|
const { relative } = require("node:path");
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const relPath = relative(cwd, fileEdit.filePath);
|
||||||
|
const displayPath = relPath.startsWith("..") ? fileEdit.filePath : relPath;
|
||||||
|
|
||||||
|
if (
|
||||||
|
t === "write" ||
|
||||||
|
t === "write_file" ||
|
||||||
|
t === "writefile" ||
|
||||||
|
t === "write_file_gemini" ||
|
||||||
|
t === "writefilegemini"
|
||||||
|
) {
|
||||||
|
const { existsSync } = require("node:fs");
|
||||||
|
try {
|
||||||
|
if (existsSync(fileEdit.filePath)) {
|
||||||
|
return `Overwrite ${displayPath}?`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
return `Write to ${displayPath}?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t === "edit" || t === "replace") {
|
||||||
|
return `Update ${displayPath}?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t === "multiedit" || t === "multi_edit") {
|
||||||
|
return `Update ${displayPath}? (${fileEdit.edits?.length || 0} edits)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Edit ${displayPath}?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine diff kind based on tool name
|
||||||
|
*/
|
||||||
|
function getDiffKind(toolName: string): "write" | "edit" | "multi_edit" {
|
||||||
|
const t = toolName.toLowerCase();
|
||||||
|
if (
|
||||||
|
t === "write" ||
|
||||||
|
t === "write_file" ||
|
||||||
|
t === "writefile" ||
|
||||||
|
t === "write_file_gemini" ||
|
||||||
|
t === "writefilegemini"
|
||||||
|
) {
|
||||||
|
return "write";
|
||||||
|
}
|
||||||
|
if (t === "multiedit" || t === "multi_edit") {
|
||||||
|
return "multi_edit";
|
||||||
|
}
|
||||||
|
return "edit";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InlineFileEditApproval - Renders file edit approval UI inline (Claude Code style)
|
||||||
|
*
|
||||||
|
* Uses horizontal lines instead of boxes for visual styling:
|
||||||
|
* - ──── solid line at top
|
||||||
|
* - ╌╌╌╌ dotted line around diff content
|
||||||
|
* - Approval options below
|
||||||
|
*/
|
||||||
|
export const InlineFileEditApproval = memo(
|
||||||
|
({
|
||||||
|
fileEdit,
|
||||||
|
precomputedDiff,
|
||||||
|
allDiffs,
|
||||||
|
onApprove,
|
||||||
|
onApproveAlways,
|
||||||
|
onDeny,
|
||||||
|
onCancel,
|
||||||
|
isFocused = true,
|
||||||
|
approveAlwaysText,
|
||||||
|
allowPersistence = true,
|
||||||
|
}: Props) => {
|
||||||
|
const [selectedOption, setSelectedOption] = useState(0);
|
||||||
|
const [customReason, setCustomReason] = useState("");
|
||||||
|
const columns = useTerminalWidth();
|
||||||
|
|
||||||
|
// Custom option index depends on whether "always" option is shown
|
||||||
|
const customOptionIndex = allowPersistence ? 2 : 1;
|
||||||
|
const maxOptionIndex = customOptionIndex;
|
||||||
|
const isOnCustomOption = selectedOption === customOptionIndex;
|
||||||
|
|
||||||
|
// Build diffs map to pass to approval handler (needed for line numbers in result)
|
||||||
|
const diffsToPass = useMemo((): Map<string, AdvancedDiffSuccess> => {
|
||||||
|
const diffs = new Map<string, AdvancedDiffSuccess>();
|
||||||
|
const toolCallId = fileEdit.toolCallId;
|
||||||
|
|
||||||
|
// For Edit/Write/MultiEdit - single file diff
|
||||||
|
if (precomputedDiff && toolCallId) {
|
||||||
|
diffs.set(toolCallId, precomputedDiff);
|
||||||
|
return diffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Patch tools - use allDiffs or parse patch input
|
||||||
|
if (fileEdit.patchInput && toolCallId) {
|
||||||
|
// First try to use allDiffs if available
|
||||||
|
if (allDiffs) {
|
||||||
|
const operations = parsePatchOperations(fileEdit.patchInput);
|
||||||
|
for (const op of operations) {
|
||||||
|
const key = `${toolCallId}:${op.path}`;
|
||||||
|
const diff = allDiffs.get(key);
|
||||||
|
if (diff) {
|
||||||
|
diffs.set(key, diff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no diffs found from allDiffs, parse patch hunks directly
|
||||||
|
if (diffs.size === 0) {
|
||||||
|
const operations = parsePatchOperations(fileEdit.patchInput);
|
||||||
|
for (const op of operations) {
|
||||||
|
const key = `${toolCallId}:${op.path}`;
|
||||||
|
if (op.kind === "add" || op.kind === "update") {
|
||||||
|
const result = parsePatchToAdvancedDiff(op.patchLines, op.path);
|
||||||
|
if (result) {
|
||||||
|
diffs.set(key, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs;
|
||||||
|
}, [fileEdit, precomputedDiff, allDiffs]);
|
||||||
|
|
||||||
|
const customOptionPlaceholder =
|
||||||
|
"No, and tell Letta Code what to do differently";
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (!isFocused) return;
|
||||||
|
|
||||||
|
// CTRL-C: cancel (queue denial, return to input)
|
||||||
|
if (key.ctrl && input === "c") {
|
||||||
|
onCancel?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow navigation always works
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedOption((prev) => Math.min(maxOptionIndex, prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on custom input option
|
||||||
|
if (isOnCustomOption) {
|
||||||
|
if (key.return) {
|
||||||
|
if (customReason.trim()) {
|
||||||
|
onDeny(customReason.trim());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
if (customReason) {
|
||||||
|
setCustomReason("");
|
||||||
|
} else {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.backspace || key.delete) {
|
||||||
|
setCustomReason((prev) => prev.slice(0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||||
|
setCustomReason((prev) => prev + input);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on regular options
|
||||||
|
if (key.return) {
|
||||||
|
if (selectedOption === 0) {
|
||||||
|
onApprove(diffsToPass.size > 0 ? diffsToPass : undefined);
|
||||||
|
} else if (selectedOption === 1 && allowPersistence) {
|
||||||
|
onApproveAlways(
|
||||||
|
"project",
|
||||||
|
diffsToPass.size > 0 ? diffsToPass : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: isFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate horizontal lines
|
||||||
|
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||||
|
const dottedLine = DOTTED_LINE.repeat(Math.max(columns - 2, 10));
|
||||||
|
const headerText = getHeaderText(fileEdit);
|
||||||
|
const diffKind = getDiffKind(fileEdit.toolName);
|
||||||
|
|
||||||
|
// Hint text based on state
|
||||||
|
const hintText = isOnCustomOption
|
||||||
|
? customReason
|
||||||
|
? "Enter to submit · Esc to clear"
|
||||||
|
: "Type reason · Esc to cancel"
|
||||||
|
: "Enter to select · Esc to cancel";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Top solid line */}
|
||||||
|
<Text dimColor>{solidLine}</Text>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Text bold color={colors.approval.header}>
|
||||||
|
{headerText}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Dotted separator before diff content */}
|
||||||
|
<Text dimColor>{dottedLine}</Text>
|
||||||
|
|
||||||
|
{/* Diff preview */}
|
||||||
|
<Box paddingLeft={0}>
|
||||||
|
{fileEdit.patchInput ? (
|
||||||
|
// Render patch operations (can be multiple files)
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{parsePatchOperations(fileEdit.patchInput).map((op, idx) => {
|
||||||
|
const { relative } = require("node:path");
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const relPath = relative(cwd, op.path);
|
||||||
|
const displayPath = relPath.startsWith("..")
|
||||||
|
? op.path
|
||||||
|
: relPath;
|
||||||
|
|
||||||
|
// Look up precomputed diff using toolCallId:path key
|
||||||
|
const diffKey = fileEdit.toolCallId
|
||||||
|
? `${fileEdit.toolCallId}:${op.path}`
|
||||||
|
: undefined;
|
||||||
|
const opDiff =
|
||||||
|
diffKey && allDiffs ? allDiffs.get(diffKey) : undefined;
|
||||||
|
|
||||||
|
if (op.kind === "add") {
|
||||||
|
return (
|
||||||
|
<Box key={`patch-add-${op.path}`} flexDirection="column">
|
||||||
|
{idx > 0 && <Box height={1} />}
|
||||||
|
<Text dimColor>{displayPath}</Text>
|
||||||
|
<AdvancedDiffRenderer
|
||||||
|
precomputed={opDiff}
|
||||||
|
kind="write"
|
||||||
|
filePath={op.path}
|
||||||
|
content={op.content}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (op.kind === "update") {
|
||||||
|
return (
|
||||||
|
<Box key={`patch-update-${op.path}`} flexDirection="column">
|
||||||
|
{idx > 0 && <Box height={1} />}
|
||||||
|
<Text dimColor>{displayPath}</Text>
|
||||||
|
<AdvancedDiffRenderer
|
||||||
|
precomputed={opDiff}
|
||||||
|
kind="edit"
|
||||||
|
filePath={op.path}
|
||||||
|
oldString={op.oldString}
|
||||||
|
newString={op.newString}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (op.kind === "delete") {
|
||||||
|
return (
|
||||||
|
<Box key={`patch-delete-${op.path}`} flexDirection="column">
|
||||||
|
{idx > 0 && <Box height={1} />}
|
||||||
|
<Text dimColor>{displayPath}</Text>
|
||||||
|
<Text color="red">File will be deleted</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
) : diffKind === "write" ? (
|
||||||
|
<AdvancedDiffRenderer
|
||||||
|
precomputed={precomputedDiff}
|
||||||
|
kind="write"
|
||||||
|
filePath={fileEdit.filePath}
|
||||||
|
content={fileEdit.content || ""}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
) : diffKind === "multi_edit" ? (
|
||||||
|
<AdvancedDiffRenderer
|
||||||
|
precomputed={precomputedDiff}
|
||||||
|
kind="multi_edit"
|
||||||
|
filePath={fileEdit.filePath}
|
||||||
|
edits={fileEdit.edits || []}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AdvancedDiffRenderer
|
||||||
|
precomputed={precomputedDiff}
|
||||||
|
kind="edit"
|
||||||
|
filePath={fileEdit.filePath}
|
||||||
|
oldString={fileEdit.oldString || ""}
|
||||||
|
newString={fileEdit.newString || ""}
|
||||||
|
replaceAll={fileEdit.replaceAll}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dotted separator after diff content */}
|
||||||
|
<Text dimColor>{dottedLine}</Text>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{/* Option 1: Yes */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedOption === 0 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOption === 0 ? "❯" : " "} 1.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
<Text
|
||||||
|
wrap="wrap"
|
||||||
|
color={
|
||||||
|
selectedOption === 0 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Option 2: Yes, always (only if persistence allowed) */}
|
||||||
|
{allowPersistence && (
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedOption === 1 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOption === 1 ? "❯" : " "} 2.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
<Text
|
||||||
|
wrap="wrap"
|
||||||
|
color={
|
||||||
|
selectedOption === 1 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{approveAlwaysText ||
|
||||||
|
"Yes, and don't ask again for this project"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom input option */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={isOnCustomOption ? colors.approval.header : undefined}
|
||||||
|
>
|
||||||
|
{isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
{customReason ? (
|
||||||
|
<Text wrap="wrap">
|
||||||
|
{customReason}
|
||||||
|
{isOnCustomOption && "█"}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text wrap="wrap" dimColor>
|
||||||
|
{customOptionPlaceholder}
|
||||||
|
{isOnCustomOption && "█"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text dimColor>{hintText}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InlineFileEditApproval.displayName = "InlineFileEditApproval";
|
||||||
243
src/cli/components/InlineGenericApproval.tsx
Normal file
243
src/cli/components/InlineGenericApproval.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
|
import { colors } from "./colors";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
toolName: string;
|
||||||
|
toolArgs: string;
|
||||||
|
onApprove: () => void;
|
||||||
|
onApproveAlways: (scope: "project" | "session") => void;
|
||||||
|
onDeny: (reason: string) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
isFocused?: boolean;
|
||||||
|
approveAlwaysText?: string;
|
||||||
|
allowPersistence?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal line character for Claude Code style
|
||||||
|
const SOLID_LINE = "─";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format tool arguments for display
|
||||||
|
*/
|
||||||
|
function formatArgs(toolArgs: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolArgs);
|
||||||
|
// Pretty print with 2-space indent, but limit length
|
||||||
|
const formatted = JSON.stringify(parsed, null, 2);
|
||||||
|
// Truncate if too long
|
||||||
|
if (formatted.length > 500) {
|
||||||
|
return `${formatted.slice(0, 500)}\n...`;
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
} catch {
|
||||||
|
// If not valid JSON, return as-is
|
||||||
|
return toolArgs || "(no arguments)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InlineGenericApproval - Renders generic tool approval UI inline
|
||||||
|
*
|
||||||
|
* Used as fallback for any tool not handled by specialized inline components.
|
||||||
|
*/
|
||||||
|
export const InlineGenericApproval = memo(
|
||||||
|
({
|
||||||
|
toolName,
|
||||||
|
toolArgs,
|
||||||
|
onApprove,
|
||||||
|
onApproveAlways,
|
||||||
|
onDeny,
|
||||||
|
onCancel,
|
||||||
|
isFocused = true,
|
||||||
|
approveAlwaysText,
|
||||||
|
allowPersistence = true,
|
||||||
|
}: Props) => {
|
||||||
|
const [selectedOption, setSelectedOption] = useState(0);
|
||||||
|
const [customReason, setCustomReason] = useState("");
|
||||||
|
const columns = useTerminalWidth();
|
||||||
|
|
||||||
|
// Custom option index depends on whether "always" option is shown
|
||||||
|
const customOptionIndex = allowPersistence ? 2 : 1;
|
||||||
|
const maxOptionIndex = customOptionIndex;
|
||||||
|
const isOnCustomOption = selectedOption === customOptionIndex;
|
||||||
|
const customOptionPlaceholder =
|
||||||
|
"No, and tell Letta Code what to do differently";
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (!isFocused) return;
|
||||||
|
|
||||||
|
// CTRL-C: cancel (queue denial, return to input)
|
||||||
|
if (key.ctrl && input === "c") {
|
||||||
|
onCancel?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow navigation always works
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedOption((prev) => Math.min(maxOptionIndex, prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on custom input option
|
||||||
|
if (isOnCustomOption) {
|
||||||
|
if (key.return) {
|
||||||
|
if (customReason.trim()) {
|
||||||
|
onDeny(customReason.trim());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
if (customReason) {
|
||||||
|
setCustomReason("");
|
||||||
|
} else {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.backspace || key.delete) {
|
||||||
|
setCustomReason((prev) => prev.slice(0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||||
|
setCustomReason((prev) => prev + input);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on regular options
|
||||||
|
if (key.return) {
|
||||||
|
if (selectedOption === 0) {
|
||||||
|
onApprove();
|
||||||
|
} else if (selectedOption === 1 && allowPersistence) {
|
||||||
|
onApproveAlways("project");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: isFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate horizontal line
|
||||||
|
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||||
|
const formattedArgs = formatArgs(toolArgs);
|
||||||
|
|
||||||
|
// Hint text based on state
|
||||||
|
const hintText = isOnCustomOption
|
||||||
|
? customReason
|
||||||
|
? "Enter to submit · Esc to clear"
|
||||||
|
: "Type reason · Esc to cancel"
|
||||||
|
: "Enter to select · Esc to cancel";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Top solid line */}
|
||||||
|
<Text dimColor>{solidLine}</Text>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Text bold color={colors.approval.header}>
|
||||||
|
Run {toolName}?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Arguments preview */}
|
||||||
|
<Box paddingLeft={2} flexDirection="column">
|
||||||
|
<Text dimColor>{formattedArgs}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{/* Option 1: Yes */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedOption === 0 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOption === 0 ? "❯" : " "} 1.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
<Text
|
||||||
|
wrap="wrap"
|
||||||
|
color={
|
||||||
|
selectedOption === 0 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Option 2: Yes, always (only if persistence allowed) */}
|
||||||
|
{allowPersistence && (
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedOption === 1 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOption === 1 ? "❯" : " "} 2.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
<Text
|
||||||
|
wrap="wrap"
|
||||||
|
color={
|
||||||
|
selectedOption === 1 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{approveAlwaysText ||
|
||||||
|
"Yes, and don't ask again for this project"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom input option */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={isOnCustomOption ? colors.approval.header : undefined}
|
||||||
|
>
|
||||||
|
{isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
{customReason ? (
|
||||||
|
<Text wrap="wrap">
|
||||||
|
{customReason}
|
||||||
|
{isOnCustomOption && "█"}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text wrap="wrap" dimColor>
|
||||||
|
{customOptionPlaceholder}
|
||||||
|
{isOnCustomOption && "█"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text dimColor>{hintText}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InlineGenericApproval.displayName = "InlineGenericApproval";
|
||||||
225
src/cli/components/InlinePlanApproval.tsx
Normal file
225
src/cli/components/InlinePlanApproval.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
|
import { colors } from "./colors";
|
||||||
|
import { MarkdownDisplay } from "./MarkdownDisplay";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
plan: string;
|
||||||
|
onApprove: () => void;
|
||||||
|
onApproveAndAcceptEdits: () => void;
|
||||||
|
onKeepPlanning: (reason: string) => void;
|
||||||
|
isFocused?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal line characters for Claude Code style
|
||||||
|
const SOLID_LINE = "─";
|
||||||
|
const DOTTED_LINE = "╌";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InlinePlanApproval - Renders plan approval UI inline (Claude Code style)
|
||||||
|
*
|
||||||
|
* Uses horizontal lines instead of boxes for visual styling:
|
||||||
|
* - ──── solid line at top
|
||||||
|
* - ╌╌╌╌ dotted line around plan content
|
||||||
|
* - Approval options below
|
||||||
|
*/
|
||||||
|
export const InlinePlanApproval = memo(
|
||||||
|
({
|
||||||
|
plan,
|
||||||
|
onApprove,
|
||||||
|
onApproveAndAcceptEdits,
|
||||||
|
onKeepPlanning,
|
||||||
|
isFocused = true,
|
||||||
|
}: Props) => {
|
||||||
|
const [selectedOption, setSelectedOption] = useState(0);
|
||||||
|
const [customReason, setCustomReason] = useState("");
|
||||||
|
const columns = useTerminalWidth();
|
||||||
|
|
||||||
|
const customOptionIndex = 2;
|
||||||
|
const maxOptionIndex = customOptionIndex;
|
||||||
|
const isOnCustomOption = selectedOption === customOptionIndex;
|
||||||
|
const customOptionPlaceholder =
|
||||||
|
"Type here to tell Letta Code what to change";
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (!isFocused) return;
|
||||||
|
|
||||||
|
// CTRL-C: keep planning with cancel message
|
||||||
|
if (key.ctrl && input === "c") {
|
||||||
|
onKeepPlanning("User pressed CTRL-C to cancel");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow navigation always works
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedOption((prev) => Math.min(maxOptionIndex, prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on custom input option
|
||||||
|
if (isOnCustomOption) {
|
||||||
|
if (key.return) {
|
||||||
|
if (customReason.trim()) {
|
||||||
|
onKeepPlanning(customReason.trim());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
if (customReason) {
|
||||||
|
setCustomReason("");
|
||||||
|
} else {
|
||||||
|
// Esc without text - just clear, stay on planning
|
||||||
|
onKeepPlanning("User cancelled");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.backspace || key.delete) {
|
||||||
|
setCustomReason((prev) => prev.slice(0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||||
|
setCustomReason((prev) => prev + input);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on regular options
|
||||||
|
if (key.return) {
|
||||||
|
if (selectedOption === 0) {
|
||||||
|
onApproveAndAcceptEdits();
|
||||||
|
} else if (selectedOption === 1) {
|
||||||
|
onApprove();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onKeepPlanning("User cancelled");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: isFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate horizontal lines
|
||||||
|
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||||
|
const dottedLine = DOTTED_LINE.repeat(Math.max(columns - 2, 10));
|
||||||
|
|
||||||
|
// Hint text based on state
|
||||||
|
const hintText = isOnCustomOption
|
||||||
|
? customReason
|
||||||
|
? "Enter to submit · Esc to clear"
|
||||||
|
: "Type feedback · Esc to cancel"
|
||||||
|
: "Enter to select · Esc to cancel";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
{/* Top solid line */}
|
||||||
|
<Text dimColor>{solidLine}</Text>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Text bold color={colors.approval.header}>
|
||||||
|
Ready to code? Here is your plan:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Dotted separator before plan content */}
|
||||||
|
<Text dimColor>{dottedLine}</Text>
|
||||||
|
|
||||||
|
{/* Plan content - no indentation, just like Claude Code */}
|
||||||
|
<MarkdownDisplay text={plan} />
|
||||||
|
|
||||||
|
{/* Dotted separator after plan content */}
|
||||||
|
<Text dimColor>{dottedLine}</Text>
|
||||||
|
|
||||||
|
{/* Question */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text>Would you like to proceed?</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{/* Option 1: Yes, and auto-accept edits */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedOption === 0 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOption === 0 ? "❯" : " "} 1.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
<Text
|
||||||
|
wrap="wrap"
|
||||||
|
color={
|
||||||
|
selectedOption === 0 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Yes, and auto-accept edits
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Option 2: Yes, and manually approve edits */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedOption === 1 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedOption === 1 ? "❯" : " "} 2.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
<Text
|
||||||
|
wrap="wrap"
|
||||||
|
color={
|
||||||
|
selectedOption === 1 ? colors.approval.header : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Yes, and manually approve edits
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Option 3: Custom input */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={5} flexShrink={0}>
|
||||||
|
<Text
|
||||||
|
color={isOnCustomOption ? colors.approval.header : undefined}
|
||||||
|
>
|
||||||
|
{isOnCustomOption ? "❯" : " "} 3.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||||
|
{customReason ? (
|
||||||
|
<Text wrap="wrap">
|
||||||
|
{customReason}
|
||||||
|
{isOnCustomOption && "█"}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text wrap="wrap" dimColor>
|
||||||
|
{customOptionPlaceholder}
|
||||||
|
{isOnCustomOption && "█"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text dimColor>{hintText}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InlinePlanApproval.displayName = "InlinePlanApproval";
|
||||||
384
src/cli/components/InlineQuestionApproval.tsx
Normal file
384
src/cli/components/InlineQuestionApproval.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { Fragment, memo, useState } from "react";
|
||||||
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
|
import { colors } from "./colors";
|
||||||
|
|
||||||
|
interface QuestionOption {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
question: string;
|
||||||
|
header: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
multiSelect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
questions: Question[];
|
||||||
|
onSubmit: (answers: Record<string, string>) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
isFocused?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal line character for Claude Code style
|
||||||
|
const SOLID_LINE = "─";
|
||||||
|
|
||||||
|
export const InlineQuestionApproval = memo(
|
||||||
|
({ questions, onSubmit, onCancel, isFocused = true }: Props) => {
|
||||||
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||||
|
const [selectedOption, setSelectedOption] = useState(0);
|
||||||
|
const [customText, setCustomText] = useState("");
|
||||||
|
const [selectedMulti, setSelectedMulti] = useState<Set<number>>(new Set());
|
||||||
|
const columns = useTerminalWidth();
|
||||||
|
|
||||||
|
const currentQuestion = questions[currentQuestionIndex];
|
||||||
|
|
||||||
|
// Build options list: regular options + "Type something"
|
||||||
|
// For multi-select, we also track a separate "Submit" action
|
||||||
|
const baseOptions = currentQuestion
|
||||||
|
? [
|
||||||
|
...currentQuestion.options,
|
||||||
|
{ label: "Type something.", description: "" },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// For multi-select, add Submit as a separate selectable item
|
||||||
|
const optionsWithOther = currentQuestion?.multiSelect
|
||||||
|
? [...baseOptions, { label: "Submit", description: "" }]
|
||||||
|
: baseOptions;
|
||||||
|
|
||||||
|
const customOptionIndex = baseOptions.length - 1; // "Type something" index
|
||||||
|
const submitOptionIndex = currentQuestion?.multiSelect
|
||||||
|
? optionsWithOther.length - 1
|
||||||
|
: -1; // Submit index (only for multi-select)
|
||||||
|
|
||||||
|
const isOnCustomOption = selectedOption === customOptionIndex;
|
||||||
|
const isOnSubmitOption = selectedOption === submitOptionIndex;
|
||||||
|
|
||||||
|
const handleSubmitAnswer = (answer: string) => {
|
||||||
|
if (!currentQuestion) return;
|
||||||
|
const newAnswers = {
|
||||||
|
...answers,
|
||||||
|
[currentQuestion.question]: answer,
|
||||||
|
};
|
||||||
|
setAnswers(newAnswers);
|
||||||
|
|
||||||
|
if (currentQuestionIndex < questions.length - 1) {
|
||||||
|
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||||
|
setSelectedOption(0);
|
||||||
|
setCustomText("");
|
||||||
|
setSelectedMulti(new Set());
|
||||||
|
} else {
|
||||||
|
onSubmit(newAnswers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (!isFocused || !currentQuestion) return;
|
||||||
|
|
||||||
|
// CTRL-C: cancel
|
||||||
|
if (key.ctrl && input === "c") {
|
||||||
|
onCancel?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow navigation always works
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow || key.tab) {
|
||||||
|
setSelectedOption((prev) =>
|
||||||
|
Math.min(optionsWithOther.length - 1, prev + 1),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on custom input option ("Type something")
|
||||||
|
if (isOnCustomOption) {
|
||||||
|
if (key.return) {
|
||||||
|
// Enter toggles the checkbox (same as other options)
|
||||||
|
if (currentQuestion.multiSelect) {
|
||||||
|
setSelectedMulti((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(customOptionIndex)) {
|
||||||
|
newSet.delete(customOptionIndex);
|
||||||
|
} else {
|
||||||
|
newSet.add(customOptionIndex);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Single-select: submit the custom text if any
|
||||||
|
if (customText.trim()) {
|
||||||
|
handleSubmitAnswer(customText.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input === " " && currentQuestion.multiSelect) {
|
||||||
|
// Space: if not checked, toggle + insert space. If already checked, just insert space.
|
||||||
|
if (!selectedMulti.has(customOptionIndex)) {
|
||||||
|
setSelectedMulti((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(customOptionIndex);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Always insert the space character
|
||||||
|
setCustomText((prev) => prev + " ");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
if (customText) {
|
||||||
|
setCustomText("");
|
||||||
|
} else {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.backspace || key.delete) {
|
||||||
|
setCustomText((prev) => prev.slice(0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||||
|
setCustomText((prev) => prev + input);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When on Submit option (multi-select only)
|
||||||
|
if (isOnSubmitOption) {
|
||||||
|
if (key.return) {
|
||||||
|
// Submit the selected options + custom text if "Type something" is checked
|
||||||
|
const selectedLabels: string[] = [];
|
||||||
|
for (const i of selectedMulti) {
|
||||||
|
if (i === customOptionIndex) {
|
||||||
|
// Include custom text if checkbox is checked and text was entered
|
||||||
|
if (customText.trim()) {
|
||||||
|
selectedLabels.push(customText.trim());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const label = baseOptions[i]?.label;
|
||||||
|
if (label) {
|
||||||
|
selectedLabels.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectedLabels.length > 0) {
|
||||||
|
handleSubmitAnswer(selectedLabels.join(", "));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onCancel?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC on regular options: cancel
|
||||||
|
if (key.escape) {
|
||||||
|
onCancel?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter behavior depends on single vs multi-select
|
||||||
|
if (key.return) {
|
||||||
|
if (currentQuestion.multiSelect) {
|
||||||
|
// Multi-select: Enter toggles the checkbox (only for regular options, not custom)
|
||||||
|
if (selectedOption < customOptionIndex) {
|
||||||
|
setSelectedMulti((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(selectedOption)) {
|
||||||
|
newSet.delete(selectedOption);
|
||||||
|
} else {
|
||||||
|
newSet.add(selectedOption);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single-select: Enter selects and submits
|
||||||
|
handleSubmitAnswer(optionsWithOther[selectedOption]?.label || "");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Space also toggles for multi-select (like Claude Code) - only regular options
|
||||||
|
if (input === " " && currentQuestion.multiSelect) {
|
||||||
|
if (selectedOption < customOptionIndex) {
|
||||||
|
setSelectedMulti((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(selectedOption)) {
|
||||||
|
newSet.delete(selectedOption);
|
||||||
|
} else {
|
||||||
|
newSet.add(selectedOption);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number keys for quick selection
|
||||||
|
if (input >= "1" && input <= "9") {
|
||||||
|
const optionIndex = Number.parseInt(input, 10) - 1;
|
||||||
|
if (optionIndex < optionsWithOther.length - 1) {
|
||||||
|
if (currentQuestion.multiSelect) {
|
||||||
|
setSelectedMulti((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(optionIndex)) {
|
||||||
|
newSet.delete(optionIndex);
|
||||||
|
} else {
|
||||||
|
newSet.add(optionIndex);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
handleSubmitAnswer(optionsWithOther[optionIndex]?.label || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: isFocused },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate horizontal line
|
||||||
|
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||||
|
|
||||||
|
// Hint text based on state - keep consistent to avoid jarring changes
|
||||||
|
const hintText = currentQuestion?.multiSelect
|
||||||
|
? "Enter to toggle · Arrow to navigate · Esc to cancel"
|
||||||
|
: "Enter to select · Arrow to navigate · Esc to cancel";
|
||||||
|
|
||||||
|
if (!currentQuestion) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Top solid line */}
|
||||||
|
<Text dimColor>{solidLine}</Text>
|
||||||
|
|
||||||
|
{/* Header label */}
|
||||||
|
<Text>{currentQuestion.header}</Text>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Question */}
|
||||||
|
<Text bold>{currentQuestion.question}</Text>
|
||||||
|
|
||||||
|
<Box height={1} />
|
||||||
|
|
||||||
|
{/* Progress indicator for multiple questions */}
|
||||||
|
{questions.length > 1 && (
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text dimColor>
|
||||||
|
Question {currentQuestionIndex + 1} of {questions.length}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Options - Format: ❯ N. [ ] Label (selector, number, checkbox, label) */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{optionsWithOther.map((option, index) => {
|
||||||
|
const isSelected = index === selectedOption;
|
||||||
|
const isChecked = selectedMulti.has(index);
|
||||||
|
const color = isSelected ? colors.approval.header : undefined;
|
||||||
|
const isCustomOption = index === customOptionIndex;
|
||||||
|
const isSubmitOption = index === submitOptionIndex;
|
||||||
|
|
||||||
|
// Calculate prefix width: "❯ N. " = 5 chars, "[ ] " = 4 chars for multi-select
|
||||||
|
const selectorAndNumber = 5; // "❯ N. " or " N. "
|
||||||
|
const checkboxWidth = currentQuestion.multiSelect ? 4 : 0; // "[ ] " or nothing
|
||||||
|
const prefixWidth = selectorAndNumber + checkboxWidth;
|
||||||
|
|
||||||
|
// Submit option renders differently (selector + always bold "Submit")
|
||||||
|
if (isSubmitOption) {
|
||||||
|
return (
|
||||||
|
<Box key="submit" flexDirection="column">
|
||||||
|
{/* Extra newline above Submit */}
|
||||||
|
<Box height={1} />
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={selectorAndNumber} flexShrink={0}>
|
||||||
|
<Text color={color}>
|
||||||
|
{isSelected ? "❯" : " "}
|
||||||
|
{" "}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<Text bold color={color}>
|
||||||
|
Submit
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDescription = option.description && !isCustomOption;
|
||||||
|
|
||||||
|
// Use Fragment to avoid column Box wrapper - render row and description as siblings
|
||||||
|
// Note: Can't use <> shorthand with key, so we import Fragment
|
||||||
|
return (
|
||||||
|
<Fragment key={`${option.label}-${index}`}>
|
||||||
|
<Box flexDirection="row">
|
||||||
|
{/* Selector and number */}
|
||||||
|
<Box width={selectorAndNumber} flexShrink={0}>
|
||||||
|
<Text color={color}>
|
||||||
|
{isSelected ? "❯" : " "} {index + 1}.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{/* Checkbox (for multi-select) - single Text element to avoid re-mount */}
|
||||||
|
{currentQuestion.multiSelect && (
|
||||||
|
<Box width={checkboxWidth} flexShrink={0}>
|
||||||
|
<Text color={isChecked ? "green" : color}>
|
||||||
|
[{isChecked ? "✓" : " "}]{" "}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{/* Label */}
|
||||||
|
<Box flexGrow={1} width={Math.max(0, columns - prefixWidth)}>
|
||||||
|
{isCustomOption ? (
|
||||||
|
// Custom input option ("Type something")
|
||||||
|
customText ? (
|
||||||
|
<Text wrap="wrap">
|
||||||
|
{customText}
|
||||||
|
{isSelected && "█"}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text wrap="wrap" dimColor>
|
||||||
|
{option.label}
|
||||||
|
{isSelected && "█"}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Text wrap="wrap" color={color} bold={isSelected}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{/* Description - rendered as sibling row */}
|
||||||
|
{hasDescription && (
|
||||||
|
<Box paddingLeft={prefixWidth}>
|
||||||
|
<Text dimColor>{option.description}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text dimColor>{hintText}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InlineQuestionApproval.displayName = "InlineQuestionApproval";
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { INTERRUPTED_BY_USER } from "../../constants";
|
import { INTERRUPTED_BY_USER } from "../../constants";
|
||||||
@@ -18,6 +19,14 @@ import {
|
|||||||
isTaskTool,
|
isTaskTool,
|
||||||
isTodoTool,
|
isTodoTool,
|
||||||
} from "../helpers/toolNameMapping.js";
|
} from "../helpers/toolNameMapping.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tool is AskUserQuestion
|
||||||
|
*/
|
||||||
|
function isQuestionTool(name: string): boolean {
|
||||||
|
return name === "AskUserQuestion";
|
||||||
|
}
|
||||||
|
|
||||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||||||
import { BlinkDot } from "./BlinkDot.js";
|
import { BlinkDot } from "./BlinkDot.js";
|
||||||
@@ -57,9 +66,11 @@ export const ToolCallMessage = memo(
|
|||||||
({
|
({
|
||||||
line,
|
line,
|
||||||
precomputedDiffs,
|
precomputedDiffs,
|
||||||
|
lastPlanFilePath,
|
||||||
}: {
|
}: {
|
||||||
line: ToolCallLine;
|
line: ToolCallLine;
|
||||||
precomputedDiffs?: Map<string, AdvancedDiffSuccess>;
|
precomputedDiffs?: Map<string, AdvancedDiffSuccess>;
|
||||||
|
lastPlanFilePath?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const columns = useTerminalWidth();
|
const columns = useTerminalWidth();
|
||||||
|
|
||||||
@@ -99,10 +110,20 @@ export const ToolCallMessage = memo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For AskUserQuestion, show friendly header only after completion
|
||||||
|
if (isQuestionTool(rawName)) {
|
||||||
|
if (line.phase === "finished" && line.resultOk !== false) {
|
||||||
|
displayName = "User answered Letta Code's questions:";
|
||||||
|
} else {
|
||||||
|
displayName = "Asking user questions...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Format arguments for display using the old formatting logic
|
// Format arguments for display using the old formatting logic
|
||||||
// Pass rawName to enable special formatting for file tools
|
// Pass rawName to enable special formatting for file tools
|
||||||
const formatted = formatArgsDisplay(argsText, rawName);
|
const formatted = formatArgsDisplay(argsText, rawName);
|
||||||
const args = `(${formatted.display})`;
|
// Hide args for question tool (shown in result instead)
|
||||||
|
const args = isQuestionTool(rawName) ? "" : `(${formatted.display})`;
|
||||||
|
|
||||||
const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
|
const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
|
||||||
|
|
||||||
@@ -267,6 +288,78 @@ export const ToolCallMessage = memo(
|
|||||||
// If MemoryDiffRenderer returns null, fall through to regular handling
|
// If MemoryDiffRenderer returns null, fall through to regular handling
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is AskUserQuestion - show pretty Q&A format
|
||||||
|
if (isQuestionTool(rawName) && line.resultOk !== false) {
|
||||||
|
// Parse the result to extract questions and answers
|
||||||
|
// Format: "Question"="Answer", "Question2"="Answer2"
|
||||||
|
const qaPairs: Array<{ question: string; answer: string }> = [];
|
||||||
|
const qaRegex = /"([^"]+)"="([^"]*)"/g;
|
||||||
|
const resultText = line.resultText || "";
|
||||||
|
const matches = resultText.matchAll(qaRegex);
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match[1] && match[2] !== undefined) {
|
||||||
|
qaPairs.push({ question: match[1], answer: match[2] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qaPairs.length > 0) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{qaPairs.map((qa) => (
|
||||||
|
<Box key={qa.question} flexDirection="row">
|
||||||
|
<Box width={prefixWidth} flexShrink={0}>
|
||||||
|
<Text>{prefix}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={contentWidth}>
|
||||||
|
<Text wrap="wrap">
|
||||||
|
<Text dimColor>·</Text> {qa.question}{" "}
|
||||||
|
<Text dimColor>→</Text> {qa.answer}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fall through to regular handling if parsing fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is ExitPlanMode - show plan content (faded) instead of simple message
|
||||||
|
if (rawName === "ExitPlanMode" && line.resultOk !== false) {
|
||||||
|
// Read plan file path from ref (captured before plan mode was exited)
|
||||||
|
const planFilePath = lastPlanFilePath;
|
||||||
|
let planContent = "";
|
||||||
|
|
||||||
|
if (planFilePath && existsSync(planFilePath)) {
|
||||||
|
try {
|
||||||
|
planContent = readFileSync(planFilePath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// Fall through to default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planContent) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Plan file path */}
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Box width={prefixWidth} flexShrink={0}>
|
||||||
|
<Text>{prefix}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box flexGrow={1} width={contentWidth}>
|
||||||
|
<Text dimColor>Plan saved to: {planFilePath}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{/* Plan content (faded) - indent to align with content column */}
|
||||||
|
<Box paddingLeft={prefixWidth}>
|
||||||
|
<MarkdownDisplay text={planContent} dimColor={true} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fall through to default if no plan content
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a file edit tool - show diff instead of success message
|
// Check if this is a file edit tool - show diff instead of success message
|
||||||
if (isFileEditTool(rawName) && line.resultOk !== false && line.argsText) {
|
if (isFileEditTool(rawName) && line.resultOk !== false && line.argsText) {
|
||||||
const diff = line.toolCallId
|
const diff = line.toolCallId
|
||||||
|
|||||||
@@ -104,8 +104,33 @@ export function isPlanTool(rawName: string, displayName?: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a tool requires a specialized UI dialog instead of standard approval
|
* Checks if a tool requires a specialized UI dialog instead of standard approval
|
||||||
|
* Note: ExitPlanMode, file edit/write/patch tools, and shell tools now render inline
|
||||||
|
* (not overlay), but still need this flag to bypass the standard ApprovalDialog rendering
|
||||||
*/
|
*/
|
||||||
export function isFancyUITool(name: string): boolean {
|
export function isFancyUITool(name: string): boolean {
|
||||||
|
return (
|
||||||
|
name === "AskUserQuestion" ||
|
||||||
|
name === "EnterPlanMode" ||
|
||||||
|
name === "ExitPlanMode" ||
|
||||||
|
// File edit/write/patch tools now render inline
|
||||||
|
isFileEditTool(name) ||
|
||||||
|
isFileWriteTool(name) ||
|
||||||
|
isPatchTool(name) ||
|
||||||
|
// Shell/bash tools now render inline
|
||||||
|
isShellTool(name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a tool always requires user interaction, even in yolo mode.
|
||||||
|
* These are tools that fundamentally need user input to proceed:
|
||||||
|
* - AskUserQuestion: needs user to answer questions
|
||||||
|
* - EnterPlanMode: needs user to approve entering plan mode
|
||||||
|
* - ExitPlanMode: needs user to approve the plan
|
||||||
|
*
|
||||||
|
* Other tools (bash, file edits) should respect yolo mode and auto-approve.
|
||||||
|
*/
|
||||||
|
export function alwaysRequiresUserInput(name: string): boolean {
|
||||||
return (
|
return (
|
||||||
name === "AskUserQuestion" ||
|
name === "AskUserQuestion" ||
|
||||||
name === "EnterPlanMode" ||
|
name === "EnterPlanMode" ||
|
||||||
@@ -175,14 +200,13 @@ export function isPatchTool(name: string): boolean {
|
|||||||
* Checks if a tool is a shell/bash tool
|
* Checks if a tool is a shell/bash tool
|
||||||
*/
|
*/
|
||||||
export function isShellTool(name: string): boolean {
|
export function isShellTool(name: string): boolean {
|
||||||
|
const n = name.toLowerCase();
|
||||||
return (
|
return (
|
||||||
name === "bash" ||
|
n === "bash" ||
|
||||||
name === "Bash" ||
|
n === "shell" ||
|
||||||
name === "shell" ||
|
n === "shell_command" ||
|
||||||
name === "Shell" ||
|
n === "shellcommand" ||
|
||||||
name === "shell_command" ||
|
n === "run_shell_command" ||
|
||||||
name === "ShellCommand" ||
|
n === "runshellcommand"
|
||||||
name === "run_shell_command" ||
|
|
||||||
name === "RunShellCommand"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,7 +411,6 @@ function getDefaultDecision(toolName: string): PermissionDecision {
|
|||||||
"Grep",
|
"Grep",
|
||||||
"TodoWrite",
|
"TodoWrite",
|
||||||
"BashOutput",
|
"BashOutput",
|
||||||
"ExitPlanMode",
|
|
||||||
"LS",
|
"LS",
|
||||||
// Codex toolset (snake_case) - tools that don't require approval
|
// Codex toolset (snake_case) - tools that don't require approval
|
||||||
"read_file",
|
"read_file",
|
||||||
|
|||||||
Reference in New Issue
Block a user