feat: add spice (#12)

This commit is contained in:
Charles Packer
2025-10-27 14:25:03 -07:00
committed by GitHub
parent 6f7b3bb08b
commit 938c5ae854
11 changed files with 215 additions and 34 deletions

View File

@@ -3,6 +3,9 @@
"workspaces": {
"": {
"name": "letta-code",
"dependencies": {
"ink-link": "^5.0.0",
},
"devDependencies": {
"@letta-ai/letta-client": "1.0.0-alpha.2",
"@types/bun": "latest",
@@ -141,6 +144,8 @@
"gopd": ["gopd@1.2.0", "", {}, ""],
"has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, ""],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, ""],
@@ -157,6 +162,8 @@
"ink": ["ink@5.2.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg=="],
"ink-link": ["ink-link@5.0.0", "", { "dependencies": { "terminal-link": "^5.0.0" }, "peerDependencies": { "ink": ">=6" } }, "sha512-TFDXc/0mwUW7LMjsr0/LeLxPVV5BnHDuDQff9RCgP4rb3R+V/4dIwGBZbCevcJZtQnVcW+Iz1LUrUbpq+UDwYA=="],
"ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="],
"ink-text-input": ["ink-text-input@5.0.1", "", { "dependencies": { "chalk": "^5.2.0", "type-fest": "^3.6.1" }, "peerDependencies": { "ink": "^4.0.0", "react": "^18.0.0" } }, "sha512-crnsYJalG4EhneOFnr/q+Kzw1RgmXI2KsBaLFE6mpiIKxAtJLUnvygOF2IUKO8z4nwkSkveGRBMd81RoYdRSag=="],
@@ -251,6 +258,12 @@
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, ""],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"supports-hyperlinks": ["supports-hyperlinks@4.3.0", "", { "dependencies": { "has-flag": "^5.0.1", "supports-color": "^10.0.0" } }, "sha512-i6sWEzuwadSlcr2mOnb0ktlIl+K5FVxsPXmoPfknDd2gyw4ZBIAZ5coc0NQzYqDdEYXMHy8NaY9rWwa1Q1myiQ=="],
"terminal-link": ["terminal-link@5.0.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "supports-hyperlinks": "^4.1.0" } }, "sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, ""],
"tr46": ["tr46@0.0.3", "", {}, ""],

View File

@@ -21,7 +21,9 @@
"publishConfig": {
"access": "public"
},
"dependencies": {},
"dependencies": {
"ink-link": "^5.0.0"
},
"optionalDependencies": {
"@vscode/ripgrep": "^1.17.0"
},

View File

@@ -78,12 +78,17 @@ type StaticItem =
| {
kind: "welcome";
id: string;
snapshot: { continueSession: boolean; agentId?: string };
snapshot: {
continueSession: boolean;
agentState?: Letta.AgentState | null;
terminalWidth: number;
};
}
| Line;
export default function App({
agentId,
agentState,
loadingState = "ready",
continueSession = false,
startupApproval = null,
@@ -91,6 +96,7 @@ export default function App({
tokenStreaming = true,
}: {
agentId: string;
agentState?: Letta.AgentState | null;
loadingState?:
| "assembling"
| "upserting"
@@ -310,7 +316,8 @@ export default function App({
id: `welcome-${Date.now().toString(36)}`,
snapshot: {
continueSession,
agentId: agentId !== "loading" ? agentId : undefined,
agentState,
terminalWidth: columns,
},
},
]);
@@ -325,8 +332,9 @@ export default function App({
messageHistory,
refreshDerived,
commitEligibleLines,
agentId,
continueSession,
columns,
agentState,
]);
// Fetch llmConfig when agent is ready
@@ -1119,12 +1127,19 @@ export default function App({
id: `welcome-${Date.now().toString(36)}`,
snapshot: {
continueSession,
agentId: agentId !== "loading" ? agentId : undefined,
agentState,
terminalWidth: columns,
},
},
]);
}
}, [loadingState, continueSession, agentId, messageHistory.length]);
}, [
loadingState,
continueSession,
messageHistory.length,
columns,
agentState,
]);
return (
<Box flexDirection="column" gap={1}>
@@ -1160,7 +1175,7 @@ export default function App({
<WelcomeScreen
loadingState={loadingState}
continueSession={continueSession}
agentId={agentId !== "loading" ? agentId : undefined}
agentState={agentState}
/>
)}

View File

@@ -1,6 +1,6 @@
// Import useInput from vendored Ink for bracketed paste support
import { Box, Text, useInput } from "ink";
import { type ComponentType, memo, useMemo, useState } from "react";
import { memo, useMemo, useState } from "react";
import type { ApprovalContext } from "../../permissions/analyzer";
import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff";
import { resolvePlaceholders } from "../helpers/pasteRegistry";

View File

@@ -0,0 +1,34 @@
export const shortAsciiLogo = `
██████ ██╗ ███████╗████████╗████████╗ █████╗
██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗
██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║
██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║
██████ ███████╗███████╗ ██║ ██║ ██║ ██║
╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
`;
export const longAsciiLogo = `
██████ ██╗ ███████╗████████╗████████╗ █████╗ ██████╗ ██████╗ ██████╗ ███████╗
██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║ ██║ ██║ ██║██║ ██║█████╗
██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║ ██║ ██║ ██║██║ ██║██╔══╝
██████ ███████╗███████╗ ██║ ██║ ██║ ██║ ╚██████╗╚██████╔╝██████╔╝███████╗
╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
`;
export const tinyAsciiLogo = `
██████ ██╗ ███████╗████████╗████████╗ █████╗
██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗
██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║
██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║
██████ ███████╗███████╗ ██║ ██║ ██║ ██║
╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
`;
export const asciiLogo = `
██████
██ ██
██ ▇▇ ██
██ ██
██████
`;

View File

@@ -1,5 +1,5 @@
import { Box, Text, useInput } from "ink";
import { type ComponentType, memo, useState } from "react";
import { memo, useState } from "react";
import { resolvePlaceholders } from "../helpers/pasteRegistry";
import { colors } from "./colors";
import { MarkdownDisplay } from "./MarkdownDisplay";

View File

@@ -1,4 +1,9 @@
import type { Letta } from "@letta-ai/letta-client";
import { Box, Text } from "ink";
import Link from "ink-link";
import { getVersion } from "../../version";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { asciiLogo } from "./AsciiArt";
import { colors } from "./colors";
type LoadingState =
@@ -11,43 +16,128 @@ type LoadingState =
export function WelcomeScreen({
loadingState,
continueSession,
agentId,
agentState,
terminalWidth: frozenWidth,
}: {
loadingState: LoadingState;
continueSession?: boolean;
agentId?: string;
agentState?: Letta.AgentState | null;
terminalWidth?: number;
}) {
const getInitializingMessage = () => {
if (continueSession && agentId) {
return `Resuming agent ${agentId}...`;
const currentWidth = useTerminalWidth();
const terminalWidth = frozenWidth ?? currentWidth;
const cwd = process.cwd();
const version = getVersion();
const agentId = agentState?.id;
// Split logo into lines for side-by-side rendering
const logoLines = asciiLogo.trim().split("\n");
// Determine verbosity level based on terminal width
const isWide = terminalWidth >= 120;
const isMedium = terminalWidth >= 80;
const getMemoryBlocksText = () => {
if (!agentState?.memory?.blocks) {
return null;
}
return "Creating agent...";
const blocks = agentState.memory.blocks;
const count = blocks.length;
const labels = blocks
.map((b) => b.label)
.filter(Boolean)
.join(", ");
if (isWide && labels) {
return `attached ${count} memory block${count !== 1 ? "s" : ""} (${labels})`;
}
if (isMedium) {
return `attached ${count} memory block${count !== 1 ? "s" : ""}`;
}
return null;
};
const getReadyMessage = () => {
if (continueSession && agentId) {
return `Resumed agent (${agentId}). Ready to go!`;
const getAgentMessage = () => {
if (loadingState === "ready") {
const memoryText = getMemoryBlocksText();
const baseText =
continueSession && agentId
? "Resumed agent"
: agentId
? "Created a new agent"
: "Ready to go!";
if (memoryText) {
return `${baseText}, ${memoryText}`;
}
return baseText;
}
if (agentId) {
return `Created a new agent (${agentId}). Ready to go!`;
if (loadingState === "initializing") {
return continueSession ? "Resuming agent..." : "Creating agent...";
}
return "Ready to go!";
if (loadingState === "assembling") {
return "Assembling tools...";
}
if (loadingState === "upserting") {
return "Upserting tools...";
}
if (loadingState === "checking") {
return "Checking for pending approvals...";
}
return "";
};
const stateMessages: Record<LoadingState, string> = {
assembling: "Assembling tools...",
upserting: "Upserting tools...",
initializing: getInitializingMessage(),
checking: "Checking for pending approvals...",
ready: getReadyMessage(),
const getPathLine = () => {
if (isMedium) {
return `Running in ${cwd}`;
}
return cwd;
};
const getAgentLink = () => {
if (loadingState === "ready" && agentId) {
const url = `https://app.letta.com/projects/default-project/agents/${agentId}`;
if (isWide) {
return { text: url, url };
}
if (isMedium) {
return { text: agentId, url };
}
return { text: "Click to view in ADE", url };
}
return null;
};
const agentMessage = getAgentMessage();
const pathLine = getPathLine();
const agentLink = getAgentLink();
return (
<Box flexDirection="column">
<Text bold color={colors.welcome.accent}>
Letta Code
</Text>
<Text dimColor>{stateMessages[loadingState]}</Text>
<Box flexDirection="row" marginTop={1}>
{/* Left column: Logo */}
<Box flexDirection="column" paddingLeft={1} paddingRight={2}>
{logoLines.map((line, idx) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Logo lines are static and never reorder
<Text key={idx} bold color={colors.welcome.accent}>
{idx === 0 ? ` ${line}` : line}
</Text>
))}
</Box>
{/* Right column: Text info */}
<Box flexDirection="column" marginTop={0}>
<Text bold color={colors.welcome.accent}>
Letta Code v{version}
</Text>
<Text dimColor>{pathLine}</Text>
{agentMessage && <Text dimColor>{agentMessage}</Text>}
{agentLink && (
<Link url={agentLink.url}>
<Text dimColor>{agentLink.text}</Text>
</Link>
)}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Calculates the maximum width of a multi-line ASCII art string.
* @param asciiArt The ASCII art string.
* @returns The length of the longest line in the ASCII art.
*/
export function getAsciiArtWidth(asciiArt: string): number {
if (!asciiArt) {
return 0;
}
const lines = asciiArt.split("\n");
return Math.max(...lines.map((line) => line.length));
}

View File

@@ -91,6 +91,13 @@ async function main() {
process.exit(0);
}
// Handle version flag
if (values.version) {
const { getVersion } = await import("./version");
console.log(`${getVersion()} (Letta Code)`);
process.exit(0);
}
const shouldContinue = (values.continue as boolean | undefined) ?? false;
const specifiedAgentId = (values.agent as string | undefined) ?? null;
const isHeadless = values.prompt || values.run || !process.stdin.isTTY;
@@ -176,6 +183,7 @@ async function main() {
"assembling" | "upserting" | "initializing" | "checking" | "ready"
>("assembling");
const [agentId, setAgentId] = useState<string | null>(null);
const [agentState, setAgentState] = useState<Letta.AgentState | null>(null);
const [resumeData, setResumeData] = useState<ResumeData | null>(null);
useEffect(() => {
@@ -232,6 +240,7 @@ async function main() {
}
setAgentId(agent.id);
setAgentState(agent);
setLoadingState("ready");
}
@@ -253,6 +262,7 @@ async function main() {
return React.createElement(App, {
agentId,
agentState,
loadingState,
continueSession: isResumingSession,
startupApproval: resumeData?.pendingApproval ?? null,

View File

@@ -26,7 +26,7 @@ describe("Bash background tools", () => {
// Extract bash_id from the response text
const match = startResult.content[0].text.match(/bash_(\d+)/);
expect(match).toBeDefined();
const bashId = `bash_${match![1]}`;
const bashId = `bash_${match?.[1]}`;
// Wait for command to complete
await new Promise((resolve) => setTimeout(resolve, 200));
@@ -52,7 +52,7 @@ describe("Bash background tools", () => {
});
const match = startResult.content[0].text.match(/bash_(\d+)/);
const bashId = `bash_${match![1]}`;
const bashId = `bash_${match?.[1]}`;
// Kill it (KillBash uses shell_id parameter)
const killResult = await kill_bash({ shell_id: bashId });

5
src/version.ts Normal file
View File

@@ -0,0 +1,5 @@
import packageJson from "../package.json";
export function getVersion(): string {
return packageJson.version;
}