feat: misc tool alignment (#137)
This commit is contained in:
494
src/cli/App.tsx
494
src/cli/App.tsx
@@ -1,5 +1,6 @@
|
||||
// src/cli/App.tsx
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { APIError } from "@letta-ai/letta-client/core/error";
|
||||
import type {
|
||||
AgentState,
|
||||
@@ -34,12 +35,14 @@ import { ApprovalDialog } from "./components/ApprovalDialogRich";
|
||||
// import { AssistantMessage } from "./components/AssistantMessage";
|
||||
import { AssistantMessage } from "./components/AssistantMessageRich";
|
||||
import { CommandMessage } from "./components/CommandMessage";
|
||||
import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog";
|
||||
// import { ErrorMessage } from "./components/ErrorMessage";
|
||||
import { ErrorMessage } from "./components/ErrorMessageRich";
|
||||
// import { Input } from "./components/Input";
|
||||
import { Input } from "./components/InputRich";
|
||||
import { ModelSelector } from "./components/ModelSelector";
|
||||
import { PlanModeDialog } from "./components/PlanModeDialog";
|
||||
import { QuestionDialog } from "./components/QuestionDialog";
|
||||
// import { ReasoningMessage } from "./components/ReasoningMessage";
|
||||
import { ReasoningMessage } from "./components/ReasoningMessageRich";
|
||||
import { SessionStats as SessionStatsComponent } from "./components/SessionStats";
|
||||
@@ -63,6 +66,7 @@ import {
|
||||
buildMessageContentFromDisplay,
|
||||
clearPlaceholdersInText,
|
||||
} from "./helpers/pasteRegistry";
|
||||
import { generatePlanFilePath } from "./helpers/planName";
|
||||
import { safeJsonParseOr } from "./helpers/safeJsonParse";
|
||||
import { type ApprovalRequest, drainStreamWithResume } from "./helpers/stream";
|
||||
import { getRandomThinkingMessage } from "./helpers/thinkingMessages";
|
||||
@@ -92,9 +96,72 @@ function getPlanModeReminder(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Use bundled reminder text for binary compatibility
|
||||
const { PLAN_MODE_REMINDER } = require("../agent/promptAssets");
|
||||
return PLAN_MODE_REMINDER;
|
||||
const planFilePath = permissionMode.getPlanFilePath();
|
||||
|
||||
// Generate dynamic reminder with plan file path
|
||||
return `<system-reminder>
|
||||
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
|
||||
|
||||
## Plan File Info:
|
||||
${planFilePath ? `No plan file exists yet. You should create your plan at ${planFilePath} using the Write tool.` : "No plan file path assigned."}
|
||||
|
||||
You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
|
||||
|
||||
**Plan File Guidelines:** The plan file should contain only your final recommended approach, not all alternatives considered. Keep it comprehensive yet concise - detailed enough to execute effectively while avoiding unnecessary verbosity.
|
||||
|
||||
## Enhanced Planning Workflow
|
||||
|
||||
### Phase 1: Initial Understanding
|
||||
Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions.
|
||||
|
||||
1. Understand the user's request thoroughly
|
||||
2. Explore the codebase to understand existing patterns and relevant code
|
||||
3. Use AskUserQuestion tool to clarify ambiguities in the user request up front.
|
||||
|
||||
### Phase 2: Planning
|
||||
Goal: Come up with an approach to solve the problem identified in phase 1.
|
||||
|
||||
- Provide any background context that may help with the task without prescribing the exact design itself
|
||||
- Create a detailed plan
|
||||
|
||||
### Phase 3: Synthesis
|
||||
Goal: Synthesize the perspectives from Phase 2, and ensure that it aligns with the user's intentions by asking them questions.
|
||||
|
||||
1. Collect all findings from exploration
|
||||
2. Keep track of critical files that should be read before implementing the plan
|
||||
3. Use AskUserQuestion to ask the user questions about trade offs.
|
||||
|
||||
### Phase 4: Final Plan
|
||||
Once you have all the information you need, ensure that the plan file has been updated with your synthesized recommendation including:
|
||||
|
||||
- Recommended approach with rationale
|
||||
- Key insights from different perspectives
|
||||
- Critical files that need modification
|
||||
|
||||
### Phase 5: Call ExitPlanMode
|
||||
At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call ExitPlanMode to indicate to the user that you are done planning.
|
||||
|
||||
This is critical - your turn should only end with either asking the user a question or calling ExitPlanMode. Do not stop unless it's for these 2 reasons.
|
||||
|
||||
NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
|
||||
</system-reminder>
|
||||
`;
|
||||
}
|
||||
|
||||
// Read plan content from the plan file
|
||||
function readPlanFile(): string {
|
||||
const planFilePath = permissionMode.getPlanFilePath();
|
||||
if (!planFilePath) {
|
||||
return "No plan file path set.";
|
||||
}
|
||||
if (!existsSync(planFilePath)) {
|
||||
return `Plan file not found at ${planFilePath}`;
|
||||
}
|
||||
try {
|
||||
return readFileSync(planFilePath, "utf-8");
|
||||
} catch {
|
||||
return `Failed to read plan file at ${planFilePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get skill unload reminder if skills are loaded (using cached flag)
|
||||
@@ -214,6 +281,23 @@ export default function App({
|
||||
toolArgs: string;
|
||||
} | null>(null);
|
||||
|
||||
// If we have a question approval request, show the question dialog
|
||||
const [questionApprovalPending, setQuestionApprovalPending] = useState<{
|
||||
questions: Array<{
|
||||
question: string;
|
||||
header: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
multiSelect: boolean;
|
||||
}>;
|
||||
toolCallId: string;
|
||||
} | null>(null);
|
||||
|
||||
// If we have an EnterPlanMode approval request, show the dialog
|
||||
const [enterPlanModeApprovalPending, setEnterPlanModeApprovalPending] =
|
||||
useState<{
|
||||
toolCallId: string;
|
||||
} | null>(null);
|
||||
|
||||
// Model selector state
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||
const [toolsetSelectorOpen, setToolsetSelectorOpen] = useState(false);
|
||||
@@ -365,42 +449,77 @@ export default function App({
|
||||
// Check if this is an ExitPlanMode approval - route to plan dialog
|
||||
const planApproval = approvals.find((a) => a.toolName === "ExitPlanMode");
|
||||
if (planApproval) {
|
||||
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
|
||||
planApproval.toolArgs,
|
||||
{},
|
||||
);
|
||||
const plan = (parsedArgs.plan as string) || "No plan provided";
|
||||
// Read plan from the plan file (not from toolArgs)
|
||||
const plan = readPlanFile();
|
||||
|
||||
setPlanApprovalPending({
|
||||
plan,
|
||||
toolCallId: planApproval.toolCallId,
|
||||
toolArgs: planApproval.toolArgs,
|
||||
});
|
||||
} else {
|
||||
// Regular tool approvals (may be multiple for parallel tools)
|
||||
setPendingApprovals(approvals);
|
||||
|
||||
// Analyze approval contexts for all restored approvals
|
||||
const analyzeStartupApprovals = async () => {
|
||||
try {
|
||||
const contexts = await Promise.all(
|
||||
approvals.map(async (approval) => {
|
||||
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
|
||||
approval.toolArgs,
|
||||
{},
|
||||
);
|
||||
return await analyzeToolApproval(approval.toolName, parsedArgs);
|
||||
}),
|
||||
);
|
||||
setApprovalContexts(contexts);
|
||||
} catch (error) {
|
||||
// If analysis fails, leave context as null (will show basic options)
|
||||
console.error("Failed to analyze startup approvals:", error);
|
||||
}
|
||||
};
|
||||
|
||||
analyzeStartupApprovals();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an AskUserQuestion approval - route to question dialog
|
||||
const questionApproval = approvals.find(
|
||||
(a) => a.toolName === "AskUserQuestion",
|
||||
);
|
||||
if (questionApproval) {
|
||||
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
|
||||
questionApproval.toolArgs,
|
||||
{},
|
||||
);
|
||||
const questions =
|
||||
(parsedArgs.questions as Array<{
|
||||
question: string;
|
||||
header: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
multiSelect: boolean;
|
||||
}>) || [];
|
||||
|
||||
if (questions.length > 0) {
|
||||
setQuestionApprovalPending({
|
||||
questions,
|
||||
toolCallId: questionApproval.toolCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an EnterPlanMode approval - route to enter plan mode dialog
|
||||
const enterPlanModeApproval = approvals.find(
|
||||
(a) => a.toolName === "EnterPlanMode",
|
||||
);
|
||||
if (enterPlanModeApproval) {
|
||||
setEnterPlanModeApprovalPending({
|
||||
toolCallId: enterPlanModeApproval.toolCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular tool approvals (may be multiple for parallel tools)
|
||||
setPendingApprovals(approvals);
|
||||
|
||||
// Analyze approval contexts for all restored approvals
|
||||
const analyzeStartupApprovals = async () => {
|
||||
try {
|
||||
const contexts = await Promise.all(
|
||||
approvals.map(async (approval) => {
|
||||
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
|
||||
approval.toolArgs,
|
||||
{},
|
||||
);
|
||||
return await analyzeToolApproval(approval.toolName, parsedArgs);
|
||||
}),
|
||||
);
|
||||
setApprovalContexts(contexts);
|
||||
} catch (error) {
|
||||
// If analysis fails, leave context as null (will show basic options)
|
||||
console.error("Failed to analyze startup approvals:", error);
|
||||
}
|
||||
};
|
||||
|
||||
analyzeStartupApprovals();
|
||||
}
|
||||
}, [loadingState, startupApproval, startupApprovals]);
|
||||
|
||||
@@ -553,11 +672,8 @@ export default function App({
|
||||
(a) => a.toolName === "ExitPlanMode",
|
||||
);
|
||||
if (planApproval) {
|
||||
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
|
||||
planApproval.toolArgs,
|
||||
{},
|
||||
);
|
||||
const plan = (parsedArgs.plan as string) || "No plan provided";
|
||||
// Read plan from the plan file (not from toolArgs)
|
||||
const plan = readPlanFile();
|
||||
|
||||
setPlanApprovalPending({
|
||||
plan,
|
||||
@@ -568,6 +684,45 @@ export default function App({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each approval for AskUserQuestion special case
|
||||
const questionApproval = approvalsToProcess.find(
|
||||
(a) => a.toolName === "AskUserQuestion",
|
||||
);
|
||||
if (questionApproval) {
|
||||
const parsedArgs = safeJsonParseOr<Record<string, unknown>>(
|
||||
questionApproval.toolArgs,
|
||||
{},
|
||||
);
|
||||
const questions =
|
||||
(parsedArgs.questions as Array<{
|
||||
question: string;
|
||||
header: string;
|
||||
options: Array<{ label: string; description: string }>;
|
||||
multiSelect: boolean;
|
||||
}>) || [];
|
||||
|
||||
if (questions.length > 0) {
|
||||
setQuestionApprovalPending({
|
||||
questions,
|
||||
toolCallId: questionApproval.toolCallId,
|
||||
});
|
||||
setStreaming(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check each approval for EnterPlanMode special case
|
||||
const enterPlanModeApproval = approvalsToProcess.find(
|
||||
(a) => a.toolName === "EnterPlanMode",
|
||||
);
|
||||
if (enterPlanModeApproval) {
|
||||
setEnterPlanModeApprovalPending({
|
||||
toolCallId: enterPlanModeApproval.toolCallId,
|
||||
});
|
||||
setStreaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check permissions for all approvals
|
||||
const approvalResults = await Promise.all(
|
||||
approvalsToProcess.map(async (approvalItem) => {
|
||||
@@ -642,14 +797,30 @@ export default function App({
|
||||
}),
|
||||
);
|
||||
|
||||
// Create denial results for auto-denied tools
|
||||
const autoDeniedResults = autoDenied.map((ac) => ({
|
||||
approval: ac.approval,
|
||||
reason:
|
||||
// Create denial results for auto-denied tools and update buffers
|
||||
const autoDeniedResults = autoDenied.map((ac) => {
|
||||
const reason =
|
||||
"matchedRule" in ac.permission && ac.permission.matchedRule
|
||||
? `Permission denied by rule: ${ac.permission.matchedRule}`
|
||||
: `Permission denied: ${ac.permission.reason || "Unknown reason"}`,
|
||||
}));
|
||||
: `Permission denied: ${ac.permission.reason || "Unknown reason"}`;
|
||||
|
||||
// Update buffers with tool rejection for UI
|
||||
onChunk(buffersRef.current, {
|
||||
message_type: "tool_return_message",
|
||||
id: "dummy",
|
||||
date: new Date().toISOString(),
|
||||
tool_call_id: ac.approval.toolCallId,
|
||||
tool_return: `Error: request to call tool denied. User reason: ${reason}`,
|
||||
status: "error",
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
});
|
||||
|
||||
return {
|
||||
approval: ac.approval,
|
||||
reason,
|
||||
};
|
||||
});
|
||||
|
||||
// If all are auto-handled, continue immediately without showing dialog
|
||||
if (needsUserInput.length === 0) {
|
||||
@@ -1277,6 +1448,43 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /bashes command - show background shell processes
|
||||
if (msg.trim() === "/bashes") {
|
||||
const { backgroundProcesses } = await import(
|
||||
"../tools/impl/process_manager"
|
||||
);
|
||||
const cmdId = uid("cmd");
|
||||
|
||||
let output: string;
|
||||
if (backgroundProcesses.size === 0) {
|
||||
output = "No background processes running";
|
||||
} else {
|
||||
const lines = ["Background processes:"];
|
||||
for (const [id, proc] of backgroundProcesses) {
|
||||
const status =
|
||||
proc.status === "running"
|
||||
? "running"
|
||||
: proc.status === "completed"
|
||||
? `completed (exit ${proc.exitCode})`
|
||||
: `failed (exit ${proc.exitCode})`;
|
||||
lines.push(` ${id}: ${proc.command} [${status}]`);
|
||||
}
|
||||
output = lines.join("\n");
|
||||
}
|
||||
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /download command - download agent file
|
||||
if (msg.trim() === "/download") {
|
||||
const cmdId = uid("cmd");
|
||||
@@ -2213,6 +2421,180 @@ export default function App({
|
||||
[planApprovalPending, processConversation, appendError],
|
||||
);
|
||||
|
||||
const handleQuestionSubmit = useCallback(
|
||||
async (answers: Record<string, string>) => {
|
||||
if (!questionApprovalPending) return;
|
||||
|
||||
const { toolCallId, questions } = questionApprovalPending;
|
||||
setQuestionApprovalPending(null);
|
||||
|
||||
try {
|
||||
// Format the answer string like Claude Code does
|
||||
const answerParts = questions.map((q) => {
|
||||
const answer = answers[q.question] || "";
|
||||
return `"${q.question}"="${answer}"`;
|
||||
});
|
||||
const toolReturn = `User has answered your questions: ${answerParts.join(", ")}. You can now continue with the user's answers in mind.`;
|
||||
|
||||
// Update buffers with tool return
|
||||
onChunk(buffersRef.current, {
|
||||
message_type: "tool_return_message",
|
||||
id: "dummy",
|
||||
date: new Date().toISOString(),
|
||||
tool_call_id: toolCallId,
|
||||
tool_return: toolReturn,
|
||||
status: "success",
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
});
|
||||
|
||||
// Rotate to a new thinking message
|
||||
setThinkingMessage(getRandomThinkingMessage());
|
||||
refreshDerived();
|
||||
|
||||
// Restart conversation loop with the answer
|
||||
await processConversation([
|
||||
{
|
||||
type: "approval",
|
||||
approvals: [
|
||||
{
|
||||
type: "tool",
|
||||
tool_call_id: toolCallId,
|
||||
tool_return: toolReturn,
|
||||
status: "success",
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
appendError(String(e));
|
||||
setStreaming(false);
|
||||
}
|
||||
},
|
||||
[questionApprovalPending, processConversation, appendError, refreshDerived],
|
||||
);
|
||||
|
||||
const handleEnterPlanModeApprove = useCallback(async () => {
|
||||
if (!enterPlanModeApprovalPending) return;
|
||||
|
||||
const { toolCallId } = enterPlanModeApprovalPending;
|
||||
setEnterPlanModeApprovalPending(null);
|
||||
|
||||
// Generate plan file path
|
||||
const planFilePath = generatePlanFilePath();
|
||||
|
||||
// Toggle plan mode on and store plan file path
|
||||
permissionMode.setMode("plan");
|
||||
permissionMode.setPlanFilePath(planFilePath);
|
||||
setUiPermissionMode("plan");
|
||||
|
||||
// Get the tool return message from the implementation
|
||||
const toolReturn = `Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.
|
||||
|
||||
In plan mode, you should:
|
||||
1. Thoroughly explore the codebase to understand existing patterns
|
||||
2. Identify similar features and architectural approaches
|
||||
3. Consider multiple approaches and their trade-offs
|
||||
4. Use AskUserQuestion if you need to clarify the approach
|
||||
5. Design a concrete implementation strategy
|
||||
6. When ready, use ExitPlanMode to present your plan for approval
|
||||
|
||||
Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.
|
||||
|
||||
Plan file path: ${planFilePath}`;
|
||||
|
||||
try {
|
||||
// Update buffers with tool return
|
||||
onChunk(buffersRef.current, {
|
||||
message_type: "tool_return_message",
|
||||
id: "dummy",
|
||||
date: new Date().toISOString(),
|
||||
tool_call_id: toolCallId,
|
||||
tool_return: toolReturn,
|
||||
status: "success",
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
});
|
||||
|
||||
// Rotate to a new thinking message
|
||||
setThinkingMessage(getRandomThinkingMessage());
|
||||
refreshDerived();
|
||||
|
||||
// Restart conversation loop with approval
|
||||
await processConversation([
|
||||
{
|
||||
type: "approval",
|
||||
approvals: [
|
||||
{
|
||||
type: "tool",
|
||||
tool_call_id: toolCallId,
|
||||
tool_return: toolReturn,
|
||||
status: "success",
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
appendError(String(e));
|
||||
setStreaming(false);
|
||||
}
|
||||
}, [
|
||||
enterPlanModeApprovalPending,
|
||||
processConversation,
|
||||
appendError,
|
||||
refreshDerived,
|
||||
]);
|
||||
|
||||
const handleEnterPlanModeReject = useCallback(async () => {
|
||||
if (!enterPlanModeApprovalPending) return;
|
||||
|
||||
const { toolCallId } = enterPlanModeApprovalPending;
|
||||
setEnterPlanModeApprovalPending(null);
|
||||
|
||||
const rejectionReason =
|
||||
"User chose to skip plan mode and start implementing directly.";
|
||||
|
||||
try {
|
||||
// Update buffers with tool rejection (format matches what harness sends)
|
||||
onChunk(buffersRef.current, {
|
||||
message_type: "tool_return_message",
|
||||
id: "dummy",
|
||||
date: new Date().toISOString(),
|
||||
tool_call_id: toolCallId,
|
||||
tool_return: `Error: request to call tool denied. User reason: ${rejectionReason}`,
|
||||
status: "error",
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
});
|
||||
|
||||
// Rotate to a new thinking message
|
||||
setThinkingMessage(getRandomThinkingMessage());
|
||||
refreshDerived();
|
||||
|
||||
// Restart conversation loop with rejection
|
||||
await processConversation([
|
||||
{
|
||||
type: "approval",
|
||||
approval_request_id: toolCallId,
|
||||
approve: false,
|
||||
reason: rejectionReason,
|
||||
},
|
||||
]);
|
||||
} catch (e) {
|
||||
appendError(String(e));
|
||||
setStreaming(false);
|
||||
}
|
||||
}, [
|
||||
enterPlanModeApprovalPending,
|
||||
processConversation,
|
||||
appendError,
|
||||
refreshDerived,
|
||||
]);
|
||||
|
||||
// Live area shows only in-progress items
|
||||
const liveItems = useMemo(() => {
|
||||
return lines.filter((ln) => {
|
||||
@@ -2343,7 +2725,9 @@ export default function App({
|
||||
!toolsetSelectorOpen &&
|
||||
!systemPromptSelectorOpen &&
|
||||
!agentSelectorOpen &&
|
||||
!planApprovalPending
|
||||
!planApprovalPending &&
|
||||
!questionApprovalPending &&
|
||||
!enterPlanModeApprovalPending
|
||||
}
|
||||
streaming={streaming}
|
||||
commandRunning={commandRunning}
|
||||
@@ -2412,6 +2796,28 @@ export default function App({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Question Dialog - for AskUserQuestion tool */}
|
||||
{questionApprovalPending && (
|
||||
<>
|
||||
<Box height={1} />
|
||||
<QuestionDialog
|
||||
questions={questionApprovalPending.questions}
|
||||
onSubmit={handleQuestionSubmit}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Enter Plan Mode Dialog - for EnterPlanMode tool */}
|
||||
{enterPlanModeApprovalPending && (
|
||||
<>
|
||||
<Box height={1} />
|
||||
<EnterPlanModeDialog
|
||||
onApprove={handleEnterPlanModeApprove}
|
||||
onReject={handleEnterPlanModeReject}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Approval Dialog - below live items */}
|
||||
{pendingApprovals.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -99,6 +99,13 @@ export const commands: Record<string, Command> = {
|
||||
return "Downloading agent file...";
|
||||
},
|
||||
},
|
||||
"/bashes": {
|
||||
desc: "Show background shell processes",
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to show background processes
|
||||
return "Showing background processes...";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
80
src/cli/components/EnterPlanModeDialog.tsx
Normal file
80
src/cli/components/EnterPlanModeDialog.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type Props = {
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
};
|
||||
|
||||
export const EnterPlanModeDialog = memo(({ onApprove, onReject }: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
|
||||
const options = [
|
||||
{ label: "Yes, enter plan mode", action: onApprove },
|
||||
{ label: "No, start implementing now", action: onReject },
|
||||
];
|
||||
|
||||
useInput((input, key) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.approval.header} bold>
|
||||
Enter plan mode?
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text>
|
||||
Letta wants to enter plan mode to explore and design an implementation
|
||||
approach.
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text>In plan mode, Letta 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>
|
||||
<Text> </Text>
|
||||
<Text dimColor>
|
||||
No code changes will be made until you approve the plan.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<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">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={color}>{isSelected ? ">" : " "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color} bold={isSelected}>
|
||||
{index + 1}. {option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
EnterPlanModeDialog.displayName = "EnterPlanModeDialog";
|
||||
@@ -110,6 +110,7 @@ export function Input({
|
||||
|
||||
// Handle escape key for interrupt (when streaming) or double-escape-to-clear (when not)
|
||||
useInput((_input, key) => {
|
||||
if (!visible) return;
|
||||
if (key.escape) {
|
||||
// When streaming, use Esc to interrupt
|
||||
if (streaming && onInterrupt && !interruptRequested) {
|
||||
@@ -138,6 +139,7 @@ export function Input({
|
||||
|
||||
// Handle CTRL-C for double-ctrl-c-to-exit
|
||||
useInput((input, key) => {
|
||||
if (!visible) return;
|
||||
if (input === "c" && key.ctrl) {
|
||||
if (ctrlCPressed) {
|
||||
// Second CTRL-C - call onExit callback which handles stats and exit
|
||||
@@ -156,6 +158,7 @@ export function Input({
|
||||
|
||||
// Handle Shift+Tab for permission mode cycling
|
||||
useInput((_input, key) => {
|
||||
if (!visible) return;
|
||||
if (key.shift && key.tab) {
|
||||
// Cycle through permission modes
|
||||
const modes: PermissionMode[] = [
|
||||
@@ -181,6 +184,7 @@ export function Input({
|
||||
|
||||
// Handle up/down arrow keys for wrapped text navigation and command history
|
||||
useInput((_input, key) => {
|
||||
if (!visible) return;
|
||||
// Don't interfere with autocomplete navigation
|
||||
if (isAutocompleteActive) {
|
||||
return;
|
||||
|
||||
217
src/cli/components/QuestionDialog.tsx
Normal file
217
src/cli/components/QuestionDialog.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { colors } from "./colors";
|
||||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const QuestionDialog = memo(({ questions, onSubmit }: Props) => {
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [isOtherMode, setIsOtherMode] = useState(false);
|
||||
const [otherText, setOtherText] = useState("");
|
||||
const [selectedMulti, setSelectedMulti] = useState<Set<number>>(new Set());
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
const optionsWithOther = currentQuestion
|
||||
? [
|
||||
...currentQuestion.options,
|
||||
{ label: "Other", description: "Provide a custom response" },
|
||||
]
|
||||
: [];
|
||||
|
||||
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);
|
||||
setIsOtherMode(false);
|
||||
setOtherText("");
|
||||
setSelectedMulti(new Set());
|
||||
} else {
|
||||
onSubmit(newAnswers);
|
||||
}
|
||||
};
|
||||
|
||||
useInput((input, key) => {
|
||||
if (!currentQuestion) return;
|
||||
|
||||
if (isOtherMode) {
|
||||
if (key.escape) {
|
||||
setIsOtherMode(false);
|
||||
setOtherText("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedOption((prev) =>
|
||||
Math.min(optionsWithOther.length - 1, prev + 1),
|
||||
);
|
||||
} else if (key.return) {
|
||||
if (currentQuestion.multiSelect) {
|
||||
if (selectedOption === optionsWithOther.length - 1) {
|
||||
setIsOtherMode(true);
|
||||
} else if (selectedMulti.size > 0) {
|
||||
const selectedLabels = Array.from(selectedMulti)
|
||||
.map((i) => optionsWithOther[i]?.label)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
handleSubmitAnswer(selectedLabels);
|
||||
}
|
||||
} else {
|
||||
if (selectedOption === optionsWithOther.length - 1) {
|
||||
setIsOtherMode(true);
|
||||
} else {
|
||||
handleSubmitAnswer(optionsWithOther[selectedOption]?.label || "");
|
||||
}
|
||||
}
|
||||
} else if (input === " " && currentQuestion.multiSelect) {
|
||||
if (selectedOption < optionsWithOther.length - 1) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(selectedOption)) {
|
||||
newSet.delete(selectedOption);
|
||||
} else {
|
||||
newSet.add(selectedOption);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else if (input >= "1" && input <= "9") {
|
||||
const optionIndex = Number.parseInt(input, 10) - 1;
|
||||
if (optionIndex < optionsWithOther.length) {
|
||||
if (currentQuestion.multiSelect) {
|
||||
if (optionIndex < optionsWithOther.length - 1) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(optionIndex)) {
|
||||
newSet.delete(optionIndex);
|
||||
} else {
|
||||
newSet.add(optionIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (optionIndex === optionsWithOther.length - 1) {
|
||||
setIsOtherMode(true);
|
||||
} else {
|
||||
handleSubmitAnswer(optionsWithOther[optionIndex]?.label || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleOtherSubmit = (text: string) => {
|
||||
handleSubmitAnswer(text);
|
||||
};
|
||||
|
||||
if (!currentQuestion) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.approval.header}>
|
||||
<Text bold>[{currentQuestion.header}]</Text>{" "}
|
||||
{currentQuestion.question}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{questions.length > 1 && (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Question {currentQuestionIndex + 1} of {questions.length}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isOtherMode ? (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>Type your response (Esc to cancel):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.approval.header}>> </Text>
|
||||
<PasteAwareTextInput
|
||||
value={otherText}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleOtherSubmit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{optionsWithOther.map((option, index) => {
|
||||
const isSelected = index === selectedOption;
|
||||
const isChecked = selectedMulti.has(index);
|
||||
const color = isSelected ? colors.approval.header : undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={option.label}
|
||||
flexDirection="column"
|
||||
marginBottom={index < optionsWithOther.length - 1 ? 1 : 0}
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={color}>{isSelected ? ">" : " "}</Text>
|
||||
</Box>
|
||||
{currentQuestion.multiSelect &&
|
||||
index < optionsWithOther.length - 1 && (
|
||||
<Box width={4} flexShrink={0}>
|
||||
<Text color={color}>[{isChecked ? "x" : " "}]</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color} bold={isSelected}>
|
||||
{index + 1}. {option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{option.description && (
|
||||
<Box paddingLeft={currentQuestion.multiSelect ? 6 : 2}>
|
||||
<Text dimColor>{option.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{currentQuestion.multiSelect
|
||||
? "Space to toggle, Enter to confirm selection"
|
||||
: `Enter to select, or type 1-${optionsWithOther.length}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
QuestionDialog.displayName = "QuestionDialog";
|
||||
@@ -62,6 +62,7 @@ export const ToolCallMessage = memo(({ line }: { line: ToolCallLine }) => {
|
||||
else if (displayName === "ls") displayName = "LS";
|
||||
else if (displayName === "todo_write") displayName = "TODO";
|
||||
else if (displayName === "TodoWrite") displayName = "TODO";
|
||||
else if (displayName === "EnterPlanMode") displayName = "Planning";
|
||||
else if (displayName === "ExitPlanMode") displayName = "Planning";
|
||||
// Codex toolset
|
||||
else if (displayName === "update_plan") displayName = "Plan";
|
||||
|
||||
@@ -212,4 +212,29 @@ export function backfillBuffers(buffers: Buffers, history: Message[]): void {
|
||||
break; // ignore other message types
|
||||
}
|
||||
}
|
||||
|
||||
// Mark stray tool calls as closed
|
||||
// Walk backwards: any pending tool_call before the first "transition" (non-pending-tool-call) is stray
|
||||
let foundTransition = false;
|
||||
for (let i = buffers.order.length - 1; i >= 0; i--) {
|
||||
const lineId = buffers.order[i];
|
||||
if (!lineId) continue;
|
||||
const line = buffers.byId.get(lineId);
|
||||
|
||||
if (line?.kind === "tool_call" && line.phase === "ready") {
|
||||
if (foundTransition) {
|
||||
// This is a stray - mark it closed
|
||||
buffers.byId.set(lineId, {
|
||||
...line,
|
||||
phase: "finished",
|
||||
resultText: "[Tool return not found in history]",
|
||||
resultOk: false,
|
||||
});
|
||||
}
|
||||
// else: legit pending, leave it
|
||||
} else {
|
||||
// Hit something that's not a pending tool_call - transition point
|
||||
foundTransition = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
117
src/cli/helpers/planName.ts
Normal file
117
src/cli/helpers/planName.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const adjectives = [
|
||||
"bold",
|
||||
"bright",
|
||||
"calm",
|
||||
"clever",
|
||||
"crisp",
|
||||
"daring",
|
||||
"eager",
|
||||
"fair",
|
||||
"gentle",
|
||||
"happy",
|
||||
"keen",
|
||||
"lively",
|
||||
"merry",
|
||||
"nimble",
|
||||
"playful",
|
||||
"quick",
|
||||
"radiant",
|
||||
"serene",
|
||||
"swift",
|
||||
"vivid",
|
||||
"warm",
|
||||
"witty",
|
||||
"zealous",
|
||||
"agile",
|
||||
"breezy",
|
||||
"charming",
|
||||
"dazzling",
|
||||
"elegant",
|
||||
"fancy",
|
||||
"golden",
|
||||
"humble",
|
||||
"jolly",
|
||||
"kind",
|
||||
"lucky",
|
||||
"mystic",
|
||||
"noble",
|
||||
"peaceful",
|
||||
"quiet",
|
||||
"rolling",
|
||||
"shiny",
|
||||
"tender",
|
||||
"upbeat",
|
||||
"valiant",
|
||||
"whimsy",
|
||||
"youthful",
|
||||
"zesty",
|
||||
];
|
||||
|
||||
const nouns = [
|
||||
"apple",
|
||||
"brook",
|
||||
"cloud",
|
||||
"dawn",
|
||||
"elm",
|
||||
"fern",
|
||||
"grove",
|
||||
"hill",
|
||||
"iris",
|
||||
"jade",
|
||||
"kite",
|
||||
"lake",
|
||||
"maple",
|
||||
"nest",
|
||||
"oak",
|
||||
"pine",
|
||||
"quartz",
|
||||
"river",
|
||||
"stone",
|
||||
"tide",
|
||||
"umbra",
|
||||
"vine",
|
||||
"wave",
|
||||
"yarn",
|
||||
"zenith",
|
||||
"acorn",
|
||||
"birch",
|
||||
"coral",
|
||||
"dune",
|
||||
"ember",
|
||||
"frost",
|
||||
"glade",
|
||||
"harbor",
|
||||
"ivy",
|
||||
"jasper",
|
||||
"kelp",
|
||||
"lotus",
|
||||
"moss",
|
||||
"nova",
|
||||
"opal",
|
||||
"pebble",
|
||||
"plum",
|
||||
"reed",
|
||||
"sage",
|
||||
"thorn",
|
||||
"violet",
|
||||
"willow",
|
||||
"zephyr",
|
||||
];
|
||||
|
||||
function randomElement<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)] as T;
|
||||
}
|
||||
|
||||
export function generatePlanName(): string {
|
||||
const adj1 = randomElement(adjectives);
|
||||
const adj2 = randomElement(adjectives);
|
||||
const noun = randomElement(nouns);
|
||||
return `${adj1}-${adj2}-${noun}`;
|
||||
}
|
||||
|
||||
export function generatePlanFilePath(): string {
|
||||
const name = generatePlanName();
|
||||
return `${homedir()}/.letta/plans/${name}.md`;
|
||||
}
|
||||
Reference in New Issue
Block a user