Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/cli/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,115 @@ export default function App({
// Whether an interrupt has been requested for the current stream
const [interruptRequested, setInterruptRequested] = useState(false);

// Track message history with pagination
const [messages, setMessages] = useState<LettaMessageUnion[]>(messageHistory);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [hasMoreHistory, setHasMoreHistory] = useState(true);
const [historyInitialized, setHistoryInitialized] = useState(false);
const [isLoadingInitialHistory, setIsLoadingInitialHistory] = useState(false);

// Fetch full message history on mount
useEffect(() => {
async function fetchInitialHistory() {
if (historyInitialized || agentId === "loading") {
return;
}

setHistoryInitialized(true);
setIsLoadingInitialHistory(true);

try {
const client = await getClient();

// Fetch more messages initially to have a good buffer for fast scrolling
const INITIAL_LIMIT = 200;
Copy link

Copilot AI Nov 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constant INITIAL_LIMIT = 200 is defined inside the function. Consider moving it to the file level for consistency with PAGE_SIZE and to avoid redefinition on each render.

Copilot uses AI. Check for mistakes.
const messagesPage = await client.agents.messages.list(agentId, {
limit: INITIAL_LIMIT,
});

setMessages(messagesPage.items);

// If we got fewer than requested, we have all the history
setHasMoreHistory(messagesPage.items.length >= INITIAL_LIMIT);
} catch (error) {
console.error("[History Init] Error fetching initial history:", error);
// Fall back to the passed-in messageHistory
setMessages(messageHistory);
setHasMoreHistory(messageHistory.length > 0);
} finally {
setIsLoadingInitialHistory(false);
}
}

fetchInitialHistory();
}, [agentId, historyInitialized, messageHistory]);

// Sync messageHistory updates (from new messages being sent)
useEffect(() => {
// Only update if the latest message in messageHistory is newer than in messages
if (
historyInitialized &&
messageHistory.length > 0 &&
(
messages.length === 0 ||
(messageHistory[messageHistory.length - 1]?.id !== messages[messages.length - 1]?.id)
)
) {
setMessages(messageHistory);
}
}, [messageHistory, messages, historyInitialized]);

// Fetch earlier messages for history pagination
const fetchEarlierMessages = useCallback(async () => {
if (isLoadingHistory || !hasMoreHistory || agentId === "loading") {
return;
}

try {
setIsLoadingHistory(true);
const client = await getClient();

// Get the ID of the earliest message we have
const earliestMessage = messages[0];
if (!earliestMessage) {
setHasMoreHistory(false);
return;
}

// Fetch PAGE_SIZE + 1 to determine if there are more messages beyond this batch
const PAGE_SIZE = 50;
Copy link

Copilot AI Nov 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constant PAGE_SIZE = 50 is defined inside the callback function. Consider moving it to the file level for better maintainability and to avoid redefinition on each invocation.

Copilot uses AI. Check for mistakes.
const messagesPage = await client.agents.messages.list(agentId, {
before: earliestMessage.id,
limit: PAGE_SIZE + 1,
});

const olderMessages = messagesPage.items;

if (olderMessages.length === 0) {
setHasMoreHistory(false);
} else {
// Check if there are more messages beyond this batch
const hasMore = olderMessages.length > PAGE_SIZE;

// Only take PAGE_SIZE messages (drop the +1 indicator)
const messagesToAdd = hasMore
? olderMessages.slice(0, PAGE_SIZE)
: olderMessages;

// Prepend older messages to the list
setMessages((prev) => [...messagesToAdd, ...prev]);

// Update hasMore flag
setHasMoreHistory(hasMore);
}
} catch (error) {
console.error("Error fetching earlier messages:", error);
setHasMoreHistory(false);
} finally {
setIsLoadingHistory(false);
}
}, [isLoadingHistory, hasMoreHistory, agentId, messages]);

// Whether a command is running (disables input but no streaming UI)
const [commandRunning, setCommandRunning] = useState(false);

Expand Down Expand Up @@ -1364,6 +1473,11 @@ export default function App({
onPermissionModeChange={setUiPermissionMode}
onExit={handleExit}
onInterrupt={handleInterrupt}
messageHistory={messages}
onFetchEarlierMessages={fetchEarlierMessages}
isLoadingHistory={isLoadingHistory}
hasMoreHistory={hasMoreHistory}
isLoadingInitialHistory={isLoadingInitialHistory}
interruptRequested={interruptRequested}
/>

Expand Down
107 changes: 96 additions & 11 deletions src/cli/components/InputRich.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Import useInput from vendored Ink for bracketed paste support
import type { LettaMessageUnion } from "@letta-ai/letta-client/resources/agents/messages";
import { Box, Text, useInput } from "ink";
import SpinnerLib from "ink-spinner";
import type { ComponentType } from "react";
Expand All @@ -17,6 +18,31 @@ const Spinner = SpinnerLib as ComponentType<{ type?: string }>;
// Only show token count when it exceeds this threshold
const COUNTER_VISIBLE_THRESHOLD = 1000;

// Helper function to extract user message text from message history
function extractUserMessages(messages: LettaMessageUnion[]): string[] {
return messages
.filter((msg) => msg.message_type === "user_message")
.map((msg) => {
// Handle both string and array content
if (typeof msg.content === "string") {
return msg.content;
}
// If it's an array, concatenate text parts
return msg.content
.map((part) => {
if (part.type === "text") {
return part.text || "";
}
if (part.type === "image") {
return "[Image]";
}
return "";
})
.join("");
})
.filter((text) => text.trim().length > 0);
}

export function Input({
visible = true,
streaming,
Expand All @@ -29,6 +55,11 @@ export function Input({
onExit,
onInterrupt,
interruptRequested = false,
messageHistory = [],
onFetchEarlierMessages,
isLoadingHistory = false,
hasMoreHistory = true,
isLoadingInitialHistory = false,
}: {
visible?: boolean;
streaming: boolean;
Expand All @@ -41,6 +72,11 @@ export function Input({
onExit?: () => void;
onInterrupt?: () => void;
interruptRequested?: boolean;
messageHistory?: LettaMessageUnion[];
onFetchEarlierMessages?: () => void;
isLoadingHistory?: boolean;
hasMoreHistory?: boolean;
isLoadingInitialHistory?: boolean;
}) {
const [value, setValue] = useState("");
const [escapePressed, setEscapePressed] = useState(false);
Expand All @@ -55,10 +91,36 @@ export function Input({
const [cursorPos, setCursorPos] = useState<number | undefined>(undefined);
const [currentCursorPosition, setCurrentCursorPosition] = useState(0);

// Command history
const [history, setHistory] = useState<string[]>([]);
// Command history - initialize from messageHistory
const [history, setHistory] = useState<string[]>(() =>
extractUserMessages(messageHistory),
);
const [historyIndex, setHistoryIndex] = useState(-1);
const [temporaryInput, setTemporaryInput] = useState("");
const prevHistoryLengthRef = useRef(history.length);

// Update history when messageHistory changes (e.g., after submitting a message or pagination)
useEffect(() => {
const userMessages = extractUserMessages(messageHistory);
const oldLength = prevHistoryLengthRef.current;
const newLength = userMessages.length;

setHistory(userMessages);

// If history grew (new messages were prepended), adjust historyIndex
// We use a function to get the current value without adding it to dependencies
if (newLength > oldLength) {
setHistoryIndex((currentIndex) => {
if (currentIndex !== -1) {
const lengthDiff = newLength - oldLength;
return currentIndex + lengthDiff;
}
return currentIndex;
});
}

prevHistoryLengthRef.current = newLength;
}, [messageHistory]);

// Track if we just moved to a boundary (for two-step history navigation)
const [atStartBoundary, setAtStartBoundary] = useState(false);
Expand Down Expand Up @@ -215,7 +277,9 @@ export function Input({
}

// Second press or already at start - trigger history navigation
if (history.length === 0) return;
if (history.length === 0) {
return;
}

setAtStartBoundary(false); // Reset for next time

Expand All @@ -227,8 +291,19 @@ export function Input({
setValue(history[history.length - 1] ?? "");
} else if (historyIndex > 0) {
// Go to older command
setHistoryIndex(historyIndex - 1);
setValue(history[historyIndex - 1] ?? "");
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setValue(history[newIndex] ?? "");

// Fetch earlier messages when we're near the beginning (within last 20 user messages)
if (
newIndex <= 20 &&
hasMoreHistory &&
!isLoadingHistory &&
onFetchEarlierMessages
) {
onFetchEarlierMessages();
}
}
} else if (key.downArrow) {
if (currentWrappedLine < totalWrappedLines - 1) {
Expand Down Expand Up @@ -337,12 +412,8 @@ export function Input({
}
const previousValue = value;

// Add to history if not empty and not a duplicate of the last entry
if (previousValue.trim() && previousValue !== history[history.length - 1]) {
setHistory([...history, previousValue]);
}

// Reset history navigation
// Note: history will be updated automatically via messageHistory prop
setHistoryIndex(-1);
setTemporaryInput("");

Expand Down Expand Up @@ -474,6 +545,16 @@ export function Input({
<Text dimColor>Press CTRL-C again to exit</Text>
) : escapePressed ? (
<Text dimColor>Press Esc again to clear</Text>
) : isLoadingInitialHistory ? (
<Text color={colors.status.processing}>
⏳ Loading message history...
</Text>
) : isLoadingHistory ? (
<Text color={colors.status.processing}>
⏳ Loading earlier history...
</Text>
) : historyIndex === 0 && history.length > 0 ? (
<Text dimColor>↑ Reached oldest user message</Text>
) : modeInfo ? (
<Text>
<Text color={modeInfo.color}>⏵⏵ {modeInfo.name}</Text>
Expand All @@ -485,7 +566,11 @@ export function Input({
) : (
<Text dimColor>Press / for commands or @ for files</Text>
)}
<Text dimColor>https://discord.gg/letta</Text>
<Text dimColor>
{isLoadingInitialHistory || isLoadingHistory
? "Fetching messages..."
: "https://discord.gg/letta"}
</Text>
</Box>
</Box>
</Box>
Expand Down
Loading