diff --git a/bun.lock b/bun.lock index c707eca..d2cb45c 100644 --- a/bun.lock +++ b/bun.lock @@ -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", "", {}, ""], diff --git a/package.json b/package.json index 6a97e31..d1dbd82 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "publishConfig": { "access": "public" }, - "dependencies": {}, + "dependencies": { + "ink-link": "^5.0.0" + }, "optionalDependencies": { "@vscode/ripgrep": "^1.17.0" }, diff --git a/src/cli/App.tsx b/src/cli/App.tsx index e865ef2..3c1ce5d 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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 ( @@ -1160,7 +1175,7 @@ export default function App({ )} diff --git a/src/cli/components/ApprovalDialogRich.tsx b/src/cli/components/ApprovalDialogRich.tsx index 410a4d7..aeba33e 100644 --- a/src/cli/components/ApprovalDialogRich.tsx +++ b/src/cli/components/ApprovalDialogRich.tsx @@ -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"; diff --git a/src/cli/components/AsciiArt.ts b/src/cli/components/AsciiArt.ts new file mode 100644 index 0000000..a3d8547 --- /dev/null +++ b/src/cli/components/AsciiArt.ts @@ -0,0 +1,34 @@ +export const shortAsciiLogo = ` + ██████ ██╗ ███████╗████████╗████████╗ █████╗ +██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗ +██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║ +██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║ + ██████ ███████╗███████╗ ██║ ██║ ██║ ██║ + ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ +`; + +export const longAsciiLogo = ` + ██████ ██╗ ███████╗████████╗████████╗ █████╗ ██████╗ ██████╗ ██████╗ ███████╗ +██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ +██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║ ██║ ██║ ██║██║ ██║█████╗ +██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║ ██║ ██║ ██║██║ ██║██╔══╝ + ██████ ███████╗███████╗ ██║ ██║ ██║ ██║ ╚██████╗╚██████╔╝██████╔╝███████╗ + ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ +`; + +export const tinyAsciiLogo = ` + ██████ ██╗ ███████╗████████╗████████╗ █████╗ +██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗ +██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║ +██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║ + ██████ ███████╗███████╗ ██║ ██║ ██║ ██║ + ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ +`; + +export const asciiLogo = ` + ██████ +██ ██ +██ ▇▇ ██ +██ ██ + ██████ +`; diff --git a/src/cli/components/PlanModeDialog.tsx b/src/cli/components/PlanModeDialog.tsx index 5dfffb8..178c9db 100644 --- a/src/cli/components/PlanModeDialog.tsx +++ b/src/cli/components/PlanModeDialog.tsx @@ -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"; diff --git a/src/cli/components/WelcomeScreen.tsx b/src/cli/components/WelcomeScreen.tsx index e5032a4..15b503f 100644 --- a/src/cli/components/WelcomeScreen.tsx +++ b/src/cli/components/WelcomeScreen.tsx @@ -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 = { - 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 ( - - - Letta Code - - {stateMessages[loadingState]} + + {/* Left column: Logo */} + + {logoLines.map((line, idx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: Logo lines are static and never reorder + + {idx === 0 ? ` ${line}` : line} + + ))} + + + {/* Right column: Text info */} + + + Letta Code v{version} + + {pathLine} + {agentMessage && {agentMessage}} + {agentLink && ( + + {agentLink.text} + + )} + ); } diff --git a/src/cli/helpers/asciiUtils.ts b/src/cli/helpers/asciiUtils.ts new file mode 100644 index 0000000..2b2b5c9 --- /dev/null +++ b/src/cli/helpers/asciiUtils.ts @@ -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)); +} diff --git a/src/index.ts b/src/index.ts index df61ecb..5a23fe7 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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(null); + const [agentState, setAgentState] = useState(null); const [resumeData, setResumeData] = useState(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, diff --git a/src/tests/tools/bash-background.test.ts b/src/tests/tools/bash-background.test.ts index a4e9773..9c14820 100644 --- a/src/tests/tools/bash-background.test.ts +++ b/src/tests/tools/bash-background.test.ts @@ -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 }); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..bf6cc45 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,5 @@ +import packageJson from "../package.json"; + +export function getVersion(): string { + return packageJson.version; +}