feat: sync memory with filesystem tree (#685)

Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
Kevin Lin
2026-01-26 21:48:57 -08:00
committed by GitHub
parent 57f99b906b
commit 7ab97e404d
16 changed files with 1815 additions and 249 deletions

View File

@@ -42,6 +42,16 @@ import { getClient } from "../agent/client";
import { getCurrentAgentId, setCurrentAgentId } from "../agent/context";
import { type AgentProvenance, createAgent } from "../agent/create";
import { ISOLATED_BLOCK_LABELS } from "../agent/memory";
import {
detachMemoryFilesystemBlock,
ensureMemoryFilesystemBlock,
formatMemorySyncSummary,
getMemoryFilesystemRoot,
type MemorySyncConflict,
type MemorySyncResolution,
syncMemoryFilesystem,
updateMemoryFilesystemBlock,
} from "../agent/memoryFilesystem";
import { sendMessageStream } from "../agent/message";
import { getModelInfo, getModelShortName } from "../agent/model";
import { INTERRUPT_RECOVERY_ALERT } from "../agent/promptAssets";
@@ -108,6 +118,7 @@ import { ErrorMessage } from "./components/ErrorMessageRich";
import { FeedbackDialog } from "./components/FeedbackDialog";
import { HelpDialog } from "./components/HelpDialog";
import { HooksManager } from "./components/HooksManager";
import { InlineQuestionApproval } from "./components/InlineQuestionApproval";
import { Input } from "./components/InputRich";
import { McpConnectFlow } from "./components/McpConnectFlow";
import { McpSelector } from "./components/McpSelector";
@@ -118,7 +129,6 @@ import { NewAgentDialog } from "./components/NewAgentDialog";
import { PendingApprovalStub } from "./components/PendingApprovalStub";
import { PinDialog, validateAgentName } from "./components/PinDialog";
import { ProviderSelector } from "./components/ProviderSelector";
// QuestionDialog removed - now using InlineQuestionApproval
import { ReasoningMessage } from "./components/ReasoningMessageRich";
import { formatUsageStats } from "./components/SessionStats";
@@ -975,6 +985,7 @@ export default function App({
| "subagent"
| "feedback"
| "memory"
| "memory-sync"
| "pin"
| "new"
| "mcp"
@@ -984,6 +995,14 @@ export default function App({
| "connect"
| null;
const [activeOverlay, setActiveOverlay] = useState<ActiveOverlay>(null);
const [memorySyncConflicts, setMemorySyncConflicts] = useState<
MemorySyncConflict[] | null
>(null);
const memorySyncProcessedToolCallsRef = useRef<Set<string>>(new Set());
const memorySyncCommandIdRef = useRef<string | null>(null);
const memorySyncCommandInputRef = useRef<string>("/memory-sync");
const memorySyncInFlightRef = useRef(false);
const memoryFilesystemInitializedRef = useRef(false);
const [feedbackPrefill, setFeedbackPrefill] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [modelSelectorOptions, setModelSelectorOptions] = useState<{
@@ -1869,6 +1888,238 @@ export default function App({
[refreshDerived, currentModelId],
);
const updateMemorySyncCommand = useCallback(
(
commandId: string,
output: string,
success: boolean,
input = "/memory-sync",
keepRunning = false, // If true, keep phase as "running" (for conflict dialogs)
) => {
buffersRef.current.byId.set(commandId, {
kind: "command",
id: commandId,
input,
output,
phase: keepRunning ? "running" : "finished",
success,
});
refreshDerived();
},
[refreshDerived],
);
const runMemoryFilesystemSync = useCallback(
async (source: "startup" | "auto" | "command", commandId?: string) => {
if (!agentId || agentId === "loading") {
return;
}
if (memorySyncInFlightRef.current) {
return;
}
memorySyncInFlightRef.current = true;
try {
await ensureMemoryFilesystemBlock(agentId);
const result = await syncMemoryFilesystem(agentId);
if (result.conflicts.length > 0) {
memorySyncCommandIdRef.current = commandId ?? null;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memory-sync");
if (commandId) {
updateMemorySyncCommand(
commandId,
`Memory sync paused — resolve ${result.conflicts.length} conflict${
result.conflicts.length === 1 ? "" : "s"
} to continue.`,
false,
"/memory-sync",
true, // keepRunning - don't commit until conflicts resolved
);
}
return;
}
await updateMemoryFilesystemBlock(agentId);
if (commandId) {
updateMemorySyncCommand(
commandId,
formatMemorySyncSummary(result),
true,
);
}
} catch (error) {
const errorText = formatErrorDetails(error, agentId);
if (commandId) {
updateMemorySyncCommand(commandId, `Failed: ${errorText}`, false);
} else if (source !== "startup") {
appendError(`Memory sync failed: ${errorText}`);
} else {
console.error(`Memory sync failed: ${errorText}`);
}
} finally {
memorySyncInFlightRef.current = false;
}
},
[agentId, appendError, updateMemorySyncCommand],
);
const maybeSyncMemoryFilesystemAfterTurn = useCallback(async () => {
// Only auto-sync if memfs is enabled for this agent
if (!agentId || agentId === "loading") return;
if (!settingsManager.isMemfsEnabled(agentId)) return;
const newToolCallIds: string[] = [];
for (const line of buffersRef.current.byId.values()) {
if (line.kind !== "tool_call") continue;
if (!line.toolCallId || !line.name) continue;
if (line.name !== "memory" && line.name !== "memory_apply_patch")
continue;
if (memorySyncProcessedToolCallsRef.current.has(line.toolCallId))
continue;
newToolCallIds.push(line.toolCallId);
}
if (newToolCallIds.length === 0) {
return;
}
for (const id of newToolCallIds) {
memorySyncProcessedToolCallsRef.current.add(id);
}
await runMemoryFilesystemSync("auto");
}, [agentId, runMemoryFilesystemSync]);
useEffect(() => {
if (loadingState !== "ready") {
return;
}
if (!agentId || agentId === "loading") {
return;
}
if (memoryFilesystemInitializedRef.current) {
return;
}
// Only run startup sync if memfs is enabled for this agent
if (!settingsManager.isMemfsEnabled(agentId)) {
return;
}
memoryFilesystemInitializedRef.current = true;
runMemoryFilesystemSync("startup");
}, [agentId, loadingState, runMemoryFilesystemSync]);
const handleMemorySyncConflictSubmit = useCallback(
async (answers: Record<string, string>) => {
if (!agentId || agentId === "loading" || !memorySyncConflicts) {
return;
}
const commandId = memorySyncCommandIdRef.current;
const commandInput = memorySyncCommandInputRef.current;
memorySyncCommandIdRef.current = null;
memorySyncCommandInputRef.current = "/memory-sync";
const resolutions: MemorySyncResolution[] = memorySyncConflicts.map(
(conflict) => {
const answer = answers[`Conflict for ${conflict.label}`];
return {
label: conflict.label,
resolution: answer === "Use file version" ? "file" : "block",
};
},
);
setMemorySyncConflicts(null);
setActiveOverlay(null);
if (memorySyncInFlightRef.current) {
return;
}
memorySyncInFlightRef.current = true;
try {
const result = await syncMemoryFilesystem(agentId, {
resolutions,
});
if (result.conflicts.length > 0) {
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memory-sync");
if (commandId) {
updateMemorySyncCommand(
commandId,
`Memory sync paused — resolve ${result.conflicts.length} conflict${
result.conflicts.length === 1 ? "" : "s"
} to continue.`,
false,
commandInput,
true, // keepRunning - don't commit until all conflicts resolved
);
}
return;
}
await updateMemoryFilesystemBlock(agentId);
// Format resolution summary (align with formatMemorySyncSummary which uses "⎿ " prefix)
const resolutionSummary = resolutions
.map(
(r) =>
`${r.label}: used ${r.resolution === "file" ? "file" : "block"} version`,
)
.join("\n");
if (commandId) {
updateMemorySyncCommand(
commandId,
`${formatMemorySyncSummary(result)}\nConflicts resolved:\n${resolutionSummary}`,
true,
commandInput,
);
}
} catch (error) {
const errorText = formatErrorDetails(error, agentId);
if (commandId) {
updateMemorySyncCommand(
commandId,
`Failed: ${errorText}`,
false,
commandInput,
);
} else {
appendError(`Memory sync failed: ${errorText}`);
}
} finally {
memorySyncInFlightRef.current = false;
}
},
[agentId, appendError, memorySyncConflicts, updateMemorySyncCommand],
);
const handleMemorySyncConflictCancel = useCallback(() => {
const commandId = memorySyncCommandIdRef.current;
const commandInput = memorySyncCommandInputRef.current;
memorySyncCommandIdRef.current = null;
memorySyncCommandInputRef.current = "/memory-sync";
setMemorySyncConflicts(null);
setActiveOverlay(null);
if (commandId) {
updateMemorySyncCommand(
commandId,
"Memory sync cancelled.",
false,
commandInput,
);
}
}, [updateMemorySyncCommand]);
// Core streaming function - iterative loop that processes conversation turns
const processConversation = useCallback(
async (
@@ -2525,6 +2776,8 @@ export default function App({
queueSnapshotRef.current = [];
}
await maybeSyncMemoryFilesystemAfterTurn();
// === RALPH WIGGUM CONTINUATION CHECK ===
// Check if ralph mode is active and should auto-continue
// This happens at the very end, right before we'd release input
@@ -3575,6 +3828,7 @@ export default function App({
needsEagerApprovalCheck,
queueApprovalResults,
consumeQueuedMessages,
maybeSyncMemoryFilesystemAfterTurn,
],
);
@@ -5927,6 +6181,231 @@ export default function App({
return { submitted: true };
}
// Special handling for /memory-sync command - sync filesystem memory
if (trimmed === "/memory-sync") {
// Check if memfs is enabled for this agent
if (!settingsManager.isMemfsEnabled(agentId)) {
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output:
"Memory filesystem is disabled. Run `/memfs enable` first.",
phase: "finished",
success: false,
});
buffersRef.current.order.push(cmdId);
refreshDerived();
return { submitted: true };
}
const cmdId = uid("cmd");
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output: "Syncing memory filesystem...",
phase: "running",
});
buffersRef.current.order.push(cmdId);
refreshDerived();
setCommandRunning(true);
try {
await runMemoryFilesystemSync("command", cmdId);
} finally {
setCommandRunning(false);
}
return { submitted: true };
}
// Special handling for /memfs command - enable/disable filesystem-backed memory
if (trimmed.startsWith("/memfs")) {
const [, subcommand] = trimmed.split(/\s+/);
const cmdId = uid("cmd");
if (!subcommand || subcommand === "status") {
// Show status
const enabled = settingsManager.isMemfsEnabled(agentId);
let output: string;
if (enabled) {
const memoryDir = getMemoryFilesystemRoot(agentId);
output = `Memory filesystem is enabled.\nPath: ${memoryDir}`;
} else {
output =
"Memory filesystem is disabled. Run `/memfs enable` to enable.";
}
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 };
}
if (subcommand === "enable") {
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output: "Enabling memory filesystem...",
phase: "running",
});
buffersRef.current.order.push(cmdId);
refreshDerived();
setCommandRunning(true);
try {
// 1. Detach memory tools from agent
const { detachMemoryTools } = await import("../tools/toolset");
await detachMemoryTools(agentId);
// 2. Update settings
settingsManager.setMemfsEnabled(agentId, true);
// 3. Update system prompt to include memfs section
const { updateAgentSystemPromptMemfs } = await import(
"../agent/modify"
);
await updateAgentSystemPromptMemfs(agentId, true);
// 4. Run initial sync (creates files from blocks)
await ensureMemoryFilesystemBlock(agentId);
const result = await syncMemoryFilesystem(agentId);
if (result.conflicts.length > 0) {
// Handle conflicts - show overlay (keep running so it stays in liveItems)
memorySyncCommandIdRef.current = cmdId;
memorySyncCommandInputRef.current = msg;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memory-sync");
updateMemorySyncCommand(
cmdId,
`Memory filesystem enabled with ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} to resolve.`,
false,
msg,
true, // keepRunning - don't commit until conflict resolved
);
} else {
await updateMemoryFilesystemBlock(agentId);
const memoryDir = getMemoryFilesystemRoot(agentId);
updateMemorySyncCommand(
cmdId,
`Memory filesystem enabled.\nPath: ${memoryDir}\n${formatMemorySyncSummary(result)}`,
true,
msg,
);
}
} catch (error) {
const errorText =
error instanceof Error ? error.message : String(error);
updateMemorySyncCommand(
cmdId,
`Failed to enable memfs: ${errorText}`,
false,
msg,
);
} finally {
setCommandRunning(false);
}
return { submitted: true };
}
if (subcommand === "disable") {
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output: "Disabling memory filesystem...",
phase: "running",
});
buffersRef.current.order.push(cmdId);
refreshDerived();
setCommandRunning(true);
try {
// 1. Run final sync to ensure blocks are up-to-date
const result = await syncMemoryFilesystem(agentId);
if (result.conflicts.length > 0) {
// Handle conflicts - show overlay (keep running so it stays in liveItems)
memorySyncCommandIdRef.current = cmdId;
memorySyncCommandInputRef.current = msg;
setMemorySyncConflicts(result.conflicts);
setActiveOverlay("memory-sync");
updateMemorySyncCommand(
cmdId,
`Cannot disable: resolve ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} first.`,
false,
msg,
true, // keepRunning - don't commit until conflict resolved
);
return { submitted: true };
}
// 2. Re-attach memory tool
const { reattachMemoryTool } = await import("../tools/toolset");
// Use current model or default to Claude
const modelId = currentModelId || "anthropic/claude-sonnet-4";
await reattachMemoryTool(agentId, modelId);
// 3. Detach memory_filesystem block
await detachMemoryFilesystemBlock(agentId);
// 4. Update system prompt to remove memfs section
const { updateAgentSystemPromptMemfs } = await import(
"../agent/modify"
);
await updateAgentSystemPromptMemfs(agentId, false);
// 5. Update settings
settingsManager.setMemfsEnabled(agentId, false);
updateMemorySyncCommand(
cmdId,
"Memory filesystem disabled. Memory tool re-attached.\nFiles on disk have been kept.",
true,
msg,
);
} catch (error) {
const errorText =
error instanceof Error ? error.message : String(error);
updateMemorySyncCommand(
cmdId,
`Failed to disable memfs: ${errorText}`,
false,
msg,
);
} finally {
setCommandRunning(false);
}
return { submitted: true };
}
// Unknown subcommand
buffersRef.current.byId.set(cmdId, {
kind: "command",
id: cmdId,
input: msg,
output: `Unknown subcommand: ${subcommand}. Use /memfs, /memfs enable, or /memfs disable.`,
phase: "finished",
success: false,
});
buffersRef.current.order.push(cmdId);
refreshDerived();
return { submitted: true };
}
// Special handling for /skill command - enter skill creation mode
if (trimmed.startsWith("/skill")) {
// Check for pending approvals before sending
@@ -9768,6 +10247,30 @@ Plan file path: ${planFilePath}`;
/>
)}
{/* Memory Sync Conflict Resolver */}
{activeOverlay === "memory-sync" && memorySyncConflicts && (
<InlineQuestionApproval
questions={memorySyncConflicts.map((conflict) => ({
header: "Memory sync",
question: `Conflict for ${conflict.label}`,
options: [
{
label: "Use file version",
description: "Overwrite memory block with file contents",
},
{
label: "Use block version",
description: "Overwrite file with memory block contents",
},
],
multiSelect: false,
allowOther: false, // Only file or block - no custom option
}))}
onSubmit={handleMemorySyncConflictSubmit}
onCancel={handleMemorySyncConflictCancel}
/>
)}
{/* MCP Server Selector - conditionally mounted as overlay */}
{activeOverlay === "mcp" && (
<McpSelector

View File

@@ -60,6 +60,23 @@ export const commands: Record<string, Command> = {
return "Opening memory viewer...";
},
},
"/memory-sync": {
desc: "Sync memory blocks with filesystem (requires memfs enabled)",
order: 15.5,
handler: () => {
// Handled specially in App.tsx to run filesystem sync
return "Syncing memory filesystem...";
},
},
"/memfs": {
desc: "Enable/disable filesystem-backed memory (/memfs [enable|disable])",
args: "[enable|disable]",
order: 15.6,
handler: () => {
// Handled specially in App.tsx
return "Managing memory filesystem...";
},
},
"/search": {
desc: "Search messages across all agents (/search [query])",
order: 16,

View File

@@ -15,6 +15,7 @@ interface Question {
header: string;
options: QuestionOption[];
multiSelect: boolean;
allowOther?: boolean; // default true - set false to hide "Type something" option
}
type Props = {
@@ -46,12 +47,13 @@ export const InlineQuestionApproval = memo(
const currentQuestion = questions[currentQuestionIndex];
// Build options list: regular options + "Type something"
// Build options list: regular options + "Type something" (unless allowOther=false)
// For multi-select, we also track a separate "Submit" action
const showOther = currentQuestion?.allowOther !== false;
const baseOptions = currentQuestion
? [
...currentQuestion.options,
{ label: "Type something.", description: "" },
...(showOther ? [{ label: "Type something.", description: "" }] : []),
]
: [];
@@ -60,12 +62,12 @@ export const InlineQuestionApproval = memo(
? [...baseOptions, { label: "Submit", description: "" }]
: baseOptions;
const customOptionIndex = baseOptions.length - 1; // "Type something" index
const customOptionIndex = showOther ? baseOptions.length - 1 : -1; // "Type something" index (-1 if disabled)
const submitOptionIndex = currentQuestion?.multiSelect
? optionsWithOther.length - 1
: -1; // Submit index (only for multi-select)
const isOnCustomOption = selectedOption === customOptionIndex;
const isOnCustomOption = showOther && selectedOption === customOptionIndex;
const isOnSubmitOption = selectedOption === submitOptionIndex;
const handleSubmitAnswer = (answer: string) => {

View File

@@ -318,10 +318,7 @@ export function ModelSelector({
const serverRecommendedModels = useMemo(() => {
if (!isSelfHosted || availableHandles === undefined) return [];
const available = typedModels.filter(
(m) =>
availableHandles !== null &&
availableHandles.has(m.handle) &&
m.handle !== "letta/letta-free",
(m) => availableHandles?.has(m.handle) && m.handle !== "letta/letta-free",
);
if (searchQuery) {
const query = searchQuery.toLowerCase();

View File

@@ -1,236 +0,0 @@
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;
onCancel?: () => void;
};
export const QuestionDialog = memo(
({ questions, onSubmit, onCancel }: 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;
// CTRL-C: immediately cancel (works in any mode)
if (key.ctrl && input === "c") {
if (onCancel) {
onCancel();
}
return;
}
if (isOtherMode) {
if (key.escape) {
setIsOtherMode(false);
setOtherText("");
}
return;
}
// ESC in main selection mode: cancel the dialog
if (key.escape) {
if (onCancel) {
onCancel();
}
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">
<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";