234
src/cli/App.tsx
234
src/cli/App.tsx
@@ -20,6 +20,7 @@ import { linkToolsToAgent, unlinkToolsFromAgent } from "../agent/modify";
|
|||||||
import { SessionStats } from "../agent/stats";
|
import { SessionStats } from "../agent/stats";
|
||||||
import type { ApprovalContext } from "../permissions/analyzer";
|
import type { ApprovalContext } from "../permissions/analyzer";
|
||||||
import { permissionMode } from "../permissions/mode";
|
import { permissionMode } from "../permissions/mode";
|
||||||
|
import { updateProjectSettings } from "../settings";
|
||||||
import type { ToolExecutionResult } from "../tools/manager";
|
import type { ToolExecutionResult } from "../tools/manager";
|
||||||
import {
|
import {
|
||||||
analyzeToolApproval,
|
analyzeToolApproval,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
executeTool,
|
executeTool,
|
||||||
savePermissionRule,
|
savePermissionRule,
|
||||||
} from "../tools/manager";
|
} from "../tools/manager";
|
||||||
|
import { AgentSelector } from "./components/AgentSelector";
|
||||||
// import { ApprovalDialog } from "./components/ApprovalDialog";
|
// import { ApprovalDialog } from "./components/ApprovalDialog";
|
||||||
import { ApprovalDialog } from "./components/ApprovalDialogRich";
|
import { ApprovalDialog } from "./components/ApprovalDialogRich";
|
||||||
// import { AssistantMessage } from "./components/AssistantMessage";
|
// import { AssistantMessage } from "./components/AssistantMessage";
|
||||||
@@ -101,8 +103,8 @@ type StaticItem =
|
|||||||
| Line;
|
| Line;
|
||||||
|
|
||||||
export default function App({
|
export default function App({
|
||||||
agentId,
|
agentId: initialAgentId,
|
||||||
agentState,
|
agentState: initialAgentState,
|
||||||
loadingState = "ready",
|
loadingState = "ready",
|
||||||
continueSession = false,
|
continueSession = false,
|
||||||
startupApproval = null,
|
startupApproval = null,
|
||||||
@@ -126,6 +128,23 @@ export default function App({
|
|||||||
messageHistory?: LettaMessageUnion[];
|
messageHistory?: LettaMessageUnion[];
|
||||||
tokenStreaming?: boolean;
|
tokenStreaming?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
// Track current agent (can change when swapping)
|
||||||
|
const [agentId, setAgentId] = useState(initialAgentId);
|
||||||
|
const [agentState, setAgentState] = useState(initialAgentState);
|
||||||
|
|
||||||
|
// Sync with prop changes (e.g., when parent updates from "loading" to actual ID)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialAgentId !== agentId) {
|
||||||
|
setAgentId(initialAgentId);
|
||||||
|
}
|
||||||
|
}, [initialAgentId, agentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialAgentState !== agentState) {
|
||||||
|
setAgentState(initialAgentState);
|
||||||
|
}
|
||||||
|
}, [initialAgentState, agentState]);
|
||||||
|
|
||||||
// Whether a stream is in flight (disables input)
|
// Whether a stream is in flight (disables input)
|
||||||
const [streaming, setStreaming] = useState(false);
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
|
||||||
@@ -178,6 +197,9 @@ export default function App({
|
|||||||
const [llmConfig, setLlmConfig] = useState<LlmConfig | null>(null);
|
const [llmConfig, setLlmConfig] = useState<LlmConfig | null>(null);
|
||||||
const [agentName, setAgentName] = useState<string | null>(null);
|
const [agentName, setAgentName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Agent selector state
|
||||||
|
const [agentSelectorOpen, setAgentSelectorOpen] = useState(false);
|
||||||
|
|
||||||
// Token streaming preference (can be toggled at runtime)
|
// Token streaming preference (can be toggled at runtime)
|
||||||
const [tokenStreamingEnabled, setTokenStreamingEnabled] =
|
const [tokenStreamingEnabled, setTokenStreamingEnabled] =
|
||||||
useState(tokenStreaming);
|
useState(tokenStreaming);
|
||||||
@@ -1081,6 +1103,108 @@ export default function App({
|
|||||||
return { submitted: true };
|
return { submitted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for /swap command - switch to a different agent
|
||||||
|
if (msg.trim().startsWith("/swap")) {
|
||||||
|
const parts = msg.trim().split(/\s+/);
|
||||||
|
const targetAgentId = parts.slice(1).join(" ");
|
||||||
|
|
||||||
|
// If no agent ID provided, open agent selector
|
||||||
|
if (!targetAgentId) {
|
||||||
|
setAgentSelectorOpen(true);
|
||||||
|
return { submitted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and swap to specified agent ID
|
||||||
|
const cmdId = uid("cmd");
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: msg,
|
||||||
|
output: `Switching to agent ${targetAgentId}...`,
|
||||||
|
phase: "running",
|
||||||
|
});
|
||||||
|
buffersRef.current.order.push(cmdId);
|
||||||
|
refreshDerived();
|
||||||
|
|
||||||
|
setCommandRunning(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await getClient();
|
||||||
|
// Fetch new agent
|
||||||
|
const agent = await client.agents.retrieve(targetAgentId);
|
||||||
|
|
||||||
|
// Fetch agent's message history
|
||||||
|
const messagesPage =
|
||||||
|
await client.agents.messages.list(targetAgentId);
|
||||||
|
const messages = messagesPage.items;
|
||||||
|
|
||||||
|
// Update project settings with new agent
|
||||||
|
await updateProjectSettings({ lastAgent: targetAgentId });
|
||||||
|
|
||||||
|
// Clear current transcript
|
||||||
|
buffersRef.current.byId.clear();
|
||||||
|
buffersRef.current.order = [];
|
||||||
|
buffersRef.current.tokenCount = 0;
|
||||||
|
emittedIdsRef.current.clear();
|
||||||
|
setStaticItems([]);
|
||||||
|
|
||||||
|
// Update agent state
|
||||||
|
setAgentId(targetAgentId);
|
||||||
|
setAgentState(agent);
|
||||||
|
setAgentName(agent.name);
|
||||||
|
setLlmConfig(agent.llm_config);
|
||||||
|
|
||||||
|
// Add welcome screen for new agent
|
||||||
|
welcomeCommittedRef.current = false;
|
||||||
|
setStaticItems([
|
||||||
|
{
|
||||||
|
kind: "welcome",
|
||||||
|
id: `welcome-${Date.now().toString(36)}`,
|
||||||
|
snapshot: {
|
||||||
|
continueSession: true,
|
||||||
|
agentState: agent,
|
||||||
|
terminalWidth: columns,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Backfill message history
|
||||||
|
if (messages.length > 0) {
|
||||||
|
hasBackfilledRef.current = false;
|
||||||
|
backfillBuffers(buffersRef.current, messages);
|
||||||
|
refreshDerived();
|
||||||
|
commitEligibleLines(buffersRef.current);
|
||||||
|
hasBackfilledRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add success command to transcript
|
||||||
|
const successCmdId = uid("cmd");
|
||||||
|
buffersRef.current.byId.set(successCmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: successCmdId,
|
||||||
|
input: msg,
|
||||||
|
output: `✓ Switched to agent "${agent.name || targetAgentId}"`,
|
||||||
|
phase: "finished",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
buffersRef.current.order.push(successCmdId);
|
||||||
|
refreshDerived();
|
||||||
|
} catch (error) {
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: msg,
|
||||||
|
output: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
phase: "finished",
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
refreshDerived();
|
||||||
|
} finally {
|
||||||
|
setCommandRunning(false);
|
||||||
|
}
|
||||||
|
return { submitted: true };
|
||||||
|
}
|
||||||
|
|
||||||
// Immediately add command to transcript with "running" phase
|
// Immediately add command to transcript with "running" phase
|
||||||
const cmdId = uid("cmd");
|
const cmdId = uid("cmd");
|
||||||
buffersRef.current.byId.set(cmdId, {
|
buffersRef.current.byId.set(cmdId, {
|
||||||
@@ -1220,6 +1344,8 @@ export default function App({
|
|||||||
refreshDerived,
|
refreshDerived,
|
||||||
agentId,
|
agentId,
|
||||||
handleExit,
|
handleExit,
|
||||||
|
columns,
|
||||||
|
commitEligibleLines,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1493,6 +1619,100 @@ export default function App({
|
|||||||
[agentId, refreshDerived],
|
[agentId, refreshDerived],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleAgentSelect = useCallback(
|
||||||
|
async (targetAgentId: string) => {
|
||||||
|
setAgentSelectorOpen(false);
|
||||||
|
|
||||||
|
const cmdId = uid("cmd");
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: `/swap ${targetAgentId}`,
|
||||||
|
output: `Switching to agent ${targetAgentId}...`,
|
||||||
|
phase: "running",
|
||||||
|
});
|
||||||
|
buffersRef.current.order.push(cmdId);
|
||||||
|
refreshDerived();
|
||||||
|
|
||||||
|
setCommandRunning(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await getClient();
|
||||||
|
// Fetch new agent
|
||||||
|
const agent = await client.agents.retrieve(targetAgentId);
|
||||||
|
|
||||||
|
// Fetch agent's message history
|
||||||
|
const messagesPage = await client.agents.messages.list(targetAgentId);
|
||||||
|
const messages = messagesPage.items;
|
||||||
|
|
||||||
|
// Update project settings with new agent
|
||||||
|
await updateProjectSettings({ lastAgent: targetAgentId });
|
||||||
|
|
||||||
|
// Clear current transcript
|
||||||
|
buffersRef.current.byId.clear();
|
||||||
|
buffersRef.current.order = [];
|
||||||
|
buffersRef.current.tokenCount = 0;
|
||||||
|
emittedIdsRef.current.clear();
|
||||||
|
setStaticItems([]);
|
||||||
|
|
||||||
|
// Update agent state
|
||||||
|
setAgentId(targetAgentId);
|
||||||
|
setAgentState(agent);
|
||||||
|
setAgentName(agent.name);
|
||||||
|
setLlmConfig(agent.llm_config);
|
||||||
|
|
||||||
|
// Add welcome screen for new agent
|
||||||
|
welcomeCommittedRef.current = false;
|
||||||
|
setStaticItems([
|
||||||
|
{
|
||||||
|
kind: "welcome",
|
||||||
|
id: `welcome-${Date.now().toString(36)}`,
|
||||||
|
snapshot: {
|
||||||
|
continueSession: true,
|
||||||
|
agentState: agent,
|
||||||
|
terminalWidth: columns,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Backfill message history
|
||||||
|
if (messages.length > 0) {
|
||||||
|
hasBackfilledRef.current = false;
|
||||||
|
backfillBuffers(buffersRef.current, messages);
|
||||||
|
refreshDerived();
|
||||||
|
commitEligibleLines(buffersRef.current);
|
||||||
|
hasBackfilledRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add success command to transcript
|
||||||
|
const successCmdId = uid("cmd");
|
||||||
|
buffersRef.current.byId.set(successCmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: successCmdId,
|
||||||
|
input: `/swap ${targetAgentId}`,
|
||||||
|
output: `✓ Switched to agent "${agent.name || targetAgentId}"`,
|
||||||
|
phase: "finished",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
buffersRef.current.order.push(successCmdId);
|
||||||
|
refreshDerived();
|
||||||
|
} catch (error) {
|
||||||
|
buffersRef.current.byId.set(cmdId, {
|
||||||
|
kind: "command",
|
||||||
|
id: cmdId,
|
||||||
|
input: `/swap ${targetAgentId}`,
|
||||||
|
output: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
phase: "finished",
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
refreshDerived();
|
||||||
|
} finally {
|
||||||
|
setCommandRunning(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refreshDerived, commitEligibleLines, columns],
|
||||||
|
);
|
||||||
|
|
||||||
// Track permission mode changes for UI updates
|
// Track permission mode changes for UI updates
|
||||||
const [uiPermissionMode, setUiPermissionMode] = useState(
|
const [uiPermissionMode, setUiPermissionMode] = useState(
|
||||||
permissionMode.getMode(),
|
permissionMode.getMode(),
|
||||||
@@ -1716,6 +1936,7 @@ export default function App({
|
|||||||
!showExitStats &&
|
!showExitStats &&
|
||||||
pendingApprovals.length === 0 &&
|
pendingApprovals.length === 0 &&
|
||||||
!modelSelectorOpen &&
|
!modelSelectorOpen &&
|
||||||
|
!agentSelectorOpen &&
|
||||||
!planApprovalPending
|
!planApprovalPending
|
||||||
}
|
}
|
||||||
streaming={streaming}
|
streaming={streaming}
|
||||||
@@ -1741,6 +1962,15 @@ export default function App({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Agent Selector - conditionally mounted as overlay */}
|
||||||
|
{agentSelectorOpen && (
|
||||||
|
<AgentSelector
|
||||||
|
currentAgentId={agentId}
|
||||||
|
onSelect={handleAgentSelect}
|
||||||
|
onCancel={() => setAgentSelectorOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Plan Mode Dialog - below live items */}
|
{/* Plan Mode Dialog - below live items */}
|
||||||
{planApprovalPending && (
|
{planApprovalPending && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export const commands: Record<string, Command> = {
|
|||||||
return "Renaming agent...";
|
return "Renaming agent...";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/swap": {
|
||||||
|
desc: "Switch to a different agent",
|
||||||
|
handler: () => {
|
||||||
|
// Handled specially in App.tsx to access agent list and client
|
||||||
|
return "Swapping agent...";
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
184
src/cli/components/AgentSelector.tsx
Normal file
184
src/cli/components/AgentSelector.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents";
|
||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getClient } from "../../agent/client";
|
||||||
|
import { colors } from "./colors";
|
||||||
|
|
||||||
|
interface AgentSelectorProps {
|
||||||
|
currentAgentId: string;
|
||||||
|
onSelect: (agentId: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentSelector({
|
||||||
|
currentAgentId,
|
||||||
|
onSelect,
|
||||||
|
onCancel,
|
||||||
|
}: AgentSelectorProps) {
|
||||||
|
const [agents, setAgents] = useState<AgentState[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAgents = async () => {
|
||||||
|
try {
|
||||||
|
const client = await getClient();
|
||||||
|
const agentList = await client.agents.list();
|
||||||
|
setAgents(agentList.items);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAgents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounce search query (300ms delay)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedQuery(searchQuery);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Filter agents based on debounced search query
|
||||||
|
const matchingAgents = agents.filter((agent) => {
|
||||||
|
if (!debouncedQuery) return true;
|
||||||
|
const query = debouncedQuery.toLowerCase();
|
||||||
|
const name = (agent.name || "").toLowerCase();
|
||||||
|
const id = (agent.id || "").toLowerCase();
|
||||||
|
return name.includes(query) || id.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredAgents = matchingAgents.slice(0, 10);
|
||||||
|
|
||||||
|
// Reset selected index when filtered list changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (loading || error) return;
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
setSelectedIndex((prev) => Math.min(filteredAgents.length - 1, prev + 1));
|
||||||
|
} else if (key.return) {
|
||||||
|
const selectedAgent = filteredAgents[selectedIndex];
|
||||||
|
if (selectedAgent?.id) {
|
||||||
|
onSelect(selectedAgent.id);
|
||||||
|
}
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
} else if (key.backspace || key.delete) {
|
||||||
|
setSearchQuery((prev) => prev.slice(0, -1));
|
||||||
|
} else if (input && !key.ctrl && !key.meta) {
|
||||||
|
// Add regular characters to search query
|
||||||
|
setSearchQuery((prev) => prev + input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.selector.title}>Loading agents...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="red">Error loading agents: {error}</Text>
|
||||||
|
<Text dimColor>Press ESC to cancel</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agents.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.selector.title}>No agents found</Text>
|
||||||
|
<Text dimColor>Press ESC to cancel</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Text bold color={colors.selector.title}>
|
||||||
|
Select Agent (↑↓ to navigate, Enter to select, ESC to cancel)
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>Search: </Text>
|
||||||
|
<Text>{searchQuery || "_"}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{filteredAgents.length === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>No agents match your search</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredAgents.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
Showing {filteredAgents.length}
|
||||||
|
{matchingAgents.length > 10 ? ` of ${matchingAgents.length}` : ""}
|
||||||
|
{debouncedQuery ? " matching" : ""} agents
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{filteredAgents.map((agent, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
const isCurrent = agent.id === currentAgentId;
|
||||||
|
|
||||||
|
const lastInteractedAt = agent.last_run_completion
|
||||||
|
? new Date(agent.last_run_completion).toLocaleString()
|
||||||
|
: "Never";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={agent.id} flexDirection="row" gap={1}>
|
||||||
|
<Text
|
||||||
|
color={isSelected ? colors.selector.itemHighlighted : undefined}
|
||||||
|
>
|
||||||
|
{isSelected ? "›" : " "}
|
||||||
|
</Text>
|
||||||
|
<Box flexDirection="row" gap={2}>
|
||||||
|
<Text
|
||||||
|
bold={isSelected}
|
||||||
|
color={
|
||||||
|
isSelected ? colors.selector.itemHighlighted : undefined
|
||||||
|
}
|
||||||
|
wrap="truncate-end"
|
||||||
|
>
|
||||||
|
{agent.name || "Unnamed"}
|
||||||
|
{isCurrent && (
|
||||||
|
<Text color={colors.selector.itemCurrent}> (current)</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor wrap="truncate-end">
|
||||||
|
{agent.id}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor wrap="truncate-end">
|
||||||
|
{lastInteractedAt}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user