feat: misc tool alignment (#137)

This commit is contained in:
Charles Packer
2025-11-30 15:38:04 -08:00
committed by GitHub
parent b0291597f3
commit 6089ce1cdd
40 changed files with 1524 additions and 206 deletions

View File

@@ -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 && (
<>

View File

@@ -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...";
},
},
};
/**

View 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";

View File

@@ -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;

View 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}>&gt; </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";

View File

@@ -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";

View File

@@ -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
View 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`;
}