diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 8f2f6f9..14db491 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -2,14 +2,12 @@ import { existsSync, readFileSync } from "node:fs"; import { APIError, APIUserAbortError } from "@letta-ai/letta-client/core/error"; -import type { Stream } from "@letta-ai/letta-client/core/streaming"; import type { AgentState, MessageCreate, } from "@letta-ai/letta-client/resources/agents/agents"; import type { ApprovalCreate, - LettaStreamingResponse, Message, } from "@letta-ai/letta-client/resources/agents/messages"; import type { LlmConfig } from "@letta-ai/letta-client/resources/models/models"; @@ -33,28 +31,21 @@ import { savePermissionRule, } from "../tools/manager"; import { AgentSelector } from "./components/AgentSelector"; -// import { ApprovalDialog } from "./components/ApprovalDialog"; 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"; import { StatusMessage } from "./components/StatusMessage"; import { SystemPromptSelector } from "./components/SystemPromptSelector"; -// import { ToolCallMessage } from "./components/ToolCallMessage"; import { ToolCallMessage } from "./components/ToolCallMessageRich"; import { ToolsetSelector } from "./components/ToolsetSelector"; -// import { UserMessage } from "./components/UserMessage"; import { UserMessage } from "./components/UserMessageRich"; import { WelcomeScreen } from "./components/WelcomeScreen"; import { @@ -365,6 +356,9 @@ export default function App({ // Track if user wants to cancel (persists across state updates) const userCancelledRef = useRef(false); + // Message queue state for queueing messages during streaming + const [messageQueue, setMessageQueue] = useState([]); + // Track terminal shrink events to refresh static output (prevents wrapped leftovers) const columns = useTerminalWidth(); const prevColumnsRef = useRef(columns); @@ -938,12 +932,19 @@ export default function App({ }, 100); }, []); + // Handler when user presses UP/ESC to load queue into input for editing + const handleEnterQueueEditMode = useCallback(() => { + setMessageQueue([]); + }, []); + const handleInterrupt = useCallback(async () => { // If we're executing client-side tools, abort them locally instead of hitting the backend if (isExecutingTool && toolAbortControllerRef.current) { toolAbortControllerRef.current.abort(); setStreaming(false); setIsExecutingTool(false); + appendError("Stream interrupted by user"); + refreshDerived(); return; } @@ -997,6 +998,12 @@ export default function App({ refreshDerived, ]); + // Keep ref to latest processConversation to avoid circular deps in useEffect + const processConversationRef = useRef(processConversation); + useEffect(() => { + processConversationRef.current = processConversation; + }, [processConversation]); + // Reset interrupt flag when streaming ends useEffect(() => { if (!streaming) { @@ -1007,10 +1014,26 @@ export default function App({ const onSubmit = useCallback( async (message?: string): Promise<{ submitted: boolean }> => { const msg = message?.trim() ?? ""; - // Block submission while a stream is in flight, a command is running, or an approval batch - // is currently executing tools (prevents re-surfacing pending approvals mid-execution). - if (!msg || streaming || commandRunning || isExecutingTool) + if (!msg) return { submitted: false }; + + // Block submission if waiting for explicit user action (approvals) + // In this case, input is hidden anyway, so this shouldn't happen + if (pendingApprovals.length > 0) { return { submitted: false }; + } + + // Queue message if agent is busy (streaming, executing tool, or running command) + // This allows messages to queue up while agent is working + const agentBusy = streaming || isExecutingTool || commandRunning; + + if (agentBusy) { + setMessageQueue((prev) => [...prev, msg]); + return { submitted: true }; // Clears input + } + + // Reset cancellation flag when starting new submission + // This ensures that after an interrupt, new messages can be sent + userCancelledRef.current = false; // Handle commands (messages starting with "/") if (msg.startsWith("/")) { @@ -1919,9 +1942,39 @@ ${recentCommits} commitEligibleLines, isExecutingTool, queuedApprovalResults, + pendingApprovals, ], ); + const onSubmitRef = useRef(onSubmit); + useEffect(() => { + onSubmitRef.current = onSubmit; + }, [onSubmit]); + + // Process queued messages when streaming ends + useEffect(() => { + if ( + !streaming && + messageQueue.length > 0 && + pendingApprovals.length === 0 && + !commandRunning && + !isExecutingTool + ) { + const [firstMessage, ...rest] = messageQueue; + setMessageQueue(rest); + + // Submit the first message using the normal submit flow + // This ensures all setup (reminders, UI updates, etc.) happens correctly + onSubmitRef.current(firstMessage); + } + }, [ + streaming, + messageQueue, + pendingApprovals, + commandRunning, + isExecutingTool, + ]); + // Helper to send all approval results when done const sendAllResults = useCallback( async ( @@ -2936,7 +2989,6 @@ Plan file path: ${planFilePath}`; streaming={ streaming && !abortControllerRef.current?.signal.aborted } - commandRunning={commandRunning} tokenCount={tokenCount} thinkingMessage={thinkingMessage} onSubmit={onSubmit} @@ -2948,6 +3000,8 @@ Plan file path: ${planFilePath}`; agentId={agentId} agentName={agentName} currentModel={currentModelDisplay} + messageQueue={messageQueue} + onEnterQueueEditMode={handleEnterQueueEditMode} /> {/* Model Selector - conditionally mounted as overlay */} diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index d84df5e..b9678a6 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -11,6 +11,7 @@ import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; import { InputAssist } from "./InputAssist"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; +import { QueuedMessages } from "./QueuedMessages"; import { ShimmerText } from "./ShimmerText"; // Type assertion for ink-spinner compatibility @@ -23,7 +24,6 @@ const COUNTER_VISIBLE_THRESHOLD = 1000; export function Input({ visible = true, streaming, - commandRunning = false, tokenCount, thinkingMessage, onSubmit, @@ -35,10 +35,11 @@ export function Input({ agentId, agentName, currentModel, + messageQueue, + onEnterQueueEditMode, }: { visible?: boolean; streaming: boolean; - commandRunning?: boolean; tokenCount: number; thinkingMessage: string; onSubmit: (message?: string) => Promise<{ submitted: boolean }>; @@ -50,6 +51,8 @@ export function Input({ agentId?: string; agentName?: string | null; currentModel?: string | null; + messageQueue?: string[]; + onEnterQueueEditMode?: () => void; }) { const [value, setValue] = useState(""); const [escapePressed, setEscapePressed] = useState(false); @@ -119,6 +122,16 @@ export function Input({ // When streaming, use Esc to interrupt if (streaming && onInterrupt && !interruptRequested) { onInterrupt(); + + // If there are queued messages, load them into the input box + if (messageQueue && messageQueue.length > 0) { + const queueText = messageQueue.join("\n"); + setValue(queueText); + // Signal to App.tsx to clear the queue + if (onEnterQueueEditMode) { + onEnterQueueEditMode(); + } + } return; } @@ -226,7 +239,7 @@ export function Input({ } // On first wrapped line - // First press: move to start, second press: navigate history + // First press: move to start, second press: queue edit or history if (currentCursorPosition > 0 && !atStartBoundary) { // First press - move cursor to start setCursorPos(0); @@ -234,7 +247,25 @@ export function Input({ return; } - // Second press or already at start - trigger history navigation + // Check if we should load queue (streaming with queued messages) + if ( + streaming && + messageQueue && + messageQueue.length > 0 && + atStartBoundary + ) { + setAtStartBoundary(false); + // Clear the queue and load into input as one multi-line message + const queueText = messageQueue.join("\n"); + setValue(queueText); + // Signal to App.tsx to clear the queue + if (onEnterQueueEditMode) { + onEnterQueueEditMode(); + } + return; + } + + // Otherwise, trigger history navigation if (history.length === 0) return; setAtStartBoundary(false); // Reset for next time @@ -352,9 +383,6 @@ export function Input({ return; } - if (streaming || commandRunning) { - return; - } const previousValue = value; // Add to history if not empty and not a duplicate of the last entry @@ -458,6 +486,11 @@ export function Input({ )} + {/* Queue display - show when streaming with queued messages */} + {streaming && messageQueue && messageQueue.length > 0 && ( + + )} + {/* Top horizontal divider */} {horizontalLine} diff --git a/src/cli/components/QueuedMessages.tsx b/src/cli/components/QueuedMessages.tsx new file mode 100644 index 0000000..619c8cc --- /dev/null +++ b/src/cli/components/QueuedMessages.tsx @@ -0,0 +1,36 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; + +interface QueuedMessagesProps { + messages: string[]; +} + +export const QueuedMessages = memo(({ messages }: QueuedMessagesProps) => { + const maxDisplay = 5; + + return ( + + {messages.slice(0, maxDisplay).map((msg) => ( + + + {">"} + + + {msg} + + + ))} + + {messages.length > maxDisplay && ( + + + + ...and {messages.length - maxDisplay} more + + + )} + + ); +}); + +QueuedMessages.displayName = "QueuedMessages"; diff --git a/src/headless.ts b/src/headless.ts index 1ad9d1a..57e31e8 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -905,9 +905,9 @@ export async function handleHeadlessCommand( ) as Extract | undefined; const resultText = - (lastAssistant && lastAssistant.text) || - (lastReasoning && lastReasoning.text) || - (lastToolResult && lastToolResult.resultText) || + lastAssistant?.text || + lastReasoning?.text || + lastToolResult?.resultText || "No assistant response found"; // Output based on format