feat: add startup status messages showing agent provenance (#147)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
123
src/cli/App.tsx
123
src/cli/App.tsx
@@ -16,6 +16,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApprovalResult } from "../agent/approval-execution";
|
||||
import { getResumeData } from "../agent/check-approval";
|
||||
import { getClient } from "../agent/client";
|
||||
import type { AgentProvenance } from "../agent/create";
|
||||
import { sendMessageStream } from "../agent/message";
|
||||
import { linkToolsToAgent, unlinkToolsFromAgent } from "../agent/modify";
|
||||
import { SessionStats } from "../agent/stats";
|
||||
@@ -46,6 +47,7 @@ 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";
|
||||
@@ -199,6 +201,74 @@ function getSkillUnloadReminder(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Generate status lines based on agent provenance
|
||||
function generateStatusLines(
|
||||
continueSession: boolean,
|
||||
agentProvenance: AgentProvenance | null,
|
||||
agentState?: AgentState | null,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// For resumed agents
|
||||
if (continueSession) {
|
||||
lines.push(`Resumed existing agent (${agentState?.id})`);
|
||||
|
||||
// Show attached blocks if available
|
||||
if (agentState?.memory?.blocks) {
|
||||
const labels = agentState.memory.blocks
|
||||
.map((b) => b.label)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (labels) {
|
||||
lines.push(` → Memory blocks: ${labels}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(" → To create a new agent, use --new");
|
||||
return lines;
|
||||
}
|
||||
|
||||
// For new agents with provenance
|
||||
if (agentProvenance) {
|
||||
if (agentProvenance.freshBlocks) {
|
||||
lines.push(`Created new agent (${agentState?.id})`);
|
||||
const allLabels = agentProvenance.blocks.map((b) => b.label).join(", ");
|
||||
if (allLabels) {
|
||||
lines.push(` → Created new memory blocks: ${allLabels}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`Created new agent (${agentState?.id})`);
|
||||
|
||||
// Group blocks by source
|
||||
const globalBlocks = agentProvenance.blocks
|
||||
.filter((b) => b.source === "global")
|
||||
.map((b) => b.label);
|
||||
const projectBlocks = agentProvenance.blocks
|
||||
.filter((b) => b.source === "project")
|
||||
.map((b) => b.label);
|
||||
const newBlocks = agentProvenance.blocks
|
||||
.filter((b) => b.source === "new")
|
||||
.map((b) => b.label);
|
||||
|
||||
if (globalBlocks.length > 0) {
|
||||
lines.push(
|
||||
` → Reusing from global (~/.letta/): ${globalBlocks.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (projectBlocks.length > 0) {
|
||||
lines.push(
|
||||
` → Reusing from project (.letta/): ${projectBlocks.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (newBlocks.length > 0) {
|
||||
lines.push(` → Created new blocks: ${newBlocks.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Items that have finished rendering and no longer change
|
||||
type StaticItem =
|
||||
| {
|
||||
@@ -221,6 +291,7 @@ export default function App({
|
||||
startupApprovals = [],
|
||||
messageHistory = [],
|
||||
tokenStreaming = true,
|
||||
agentProvenance = null,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentState?: AgentState | null;
|
||||
@@ -237,6 +308,7 @@ export default function App({
|
||||
startupApprovals?: ApprovalRequest[];
|
||||
messageHistory?: Message[];
|
||||
tokenStreaming?: boolean;
|
||||
agentProvenance?: AgentProvenance | null;
|
||||
}) {
|
||||
// Track current agent (can change when swapping)
|
||||
const [agentId, setAgentId] = useState(initialAgentId);
|
||||
@@ -386,7 +458,7 @@ export default function App({
|
||||
const ln = b.byId.get(id);
|
||||
if (!ln) continue;
|
||||
// console.log(`[COMMIT] Checking ${id}: kind=${ln.kind}, phase=${(ln as any).phase}`);
|
||||
if (ln.kind === "user" || ln.kind === "error") {
|
||||
if (ln.kind === "user" || ln.kind === "error" || ln.kind === "status") {
|
||||
emittedIdsRef.current.add(id);
|
||||
newlyCommitted.push({ ...ln });
|
||||
// console.log(`[COMMIT] Committed ${id} (${ln.kind})`);
|
||||
@@ -511,6 +583,23 @@ export default function App({
|
||||
}
|
||||
// Use backfillBuffers to properly populate the transcript from history
|
||||
backfillBuffers(buffersRef.current, messageHistory);
|
||||
|
||||
// Inject status line at the end of the backfilled history
|
||||
const statusLines = generateStatusLines(
|
||||
continueSession,
|
||||
agentProvenance,
|
||||
agentState,
|
||||
);
|
||||
if (statusLines.length > 0) {
|
||||
const statusId = `status-${Date.now().toString(36)}`;
|
||||
buffersRef.current.byId.set(statusId, {
|
||||
kind: "status",
|
||||
id: statusId,
|
||||
lines: statusLines,
|
||||
});
|
||||
buffersRef.current.order.push(statusId);
|
||||
}
|
||||
|
||||
refreshDerived();
|
||||
commitEligibleLines(buffersRef.current);
|
||||
}
|
||||
@@ -522,6 +611,7 @@ export default function App({
|
||||
continueSession,
|
||||
columns,
|
||||
agentState,
|
||||
agentProvenance,
|
||||
]);
|
||||
|
||||
// Fetch llmConfig when agent is ready
|
||||
@@ -2715,13 +2805,32 @@ Plan file path: ${planFilePath}`;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Inject status line for fresh sessions
|
||||
const statusLines = generateStatusLines(
|
||||
continueSession,
|
||||
agentProvenance,
|
||||
agentState,
|
||||
);
|
||||
if (statusLines.length > 0) {
|
||||
const statusId = `status-${Date.now().toString(36)}`;
|
||||
buffersRef.current.byId.set(statusId, {
|
||||
kind: "status",
|
||||
id: statusId,
|
||||
lines: statusLines,
|
||||
});
|
||||
buffersRef.current.order.push(statusId);
|
||||
refreshDerived();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
loadingState,
|
||||
continueSession,
|
||||
messageHistory.length,
|
||||
columns,
|
||||
agentProvenance,
|
||||
agentState,
|
||||
refreshDerived,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -2745,9 +2854,11 @@ Plan file path: ${planFilePath}`;
|
||||
<ToolCallMessage line={item} />
|
||||
) : item.kind === "error" ? (
|
||||
<ErrorMessage line={item} />
|
||||
) : (
|
||||
) : item.kind === "status" ? (
|
||||
<StatusMessage line={item} />
|
||||
) : item.kind === "command" ? (
|
||||
<CommandMessage line={item} />
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
@@ -2779,9 +2890,11 @@ Plan file path: ${planFilePath}`;
|
||||
<ToolCallMessage line={ln} />
|
||||
) : ln.kind === "error" ? (
|
||||
<ErrorMessage line={ln} />
|
||||
) : (
|
||||
) : ln.kind === "status" ? (
|
||||
<StatusMessage line={ln} />
|
||||
) : ln.kind === "command" ? (
|
||||
<CommandMessage line={ln} />
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
41
src/cli/components/StatusMessage.tsx
Normal file
41
src/cli/components/StatusMessage.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
|
||||
type StatusLine = {
|
||||
kind: "status";
|
||||
id: string;
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* StatusMessage - Displays multi-line status messages
|
||||
*
|
||||
* Used for agent provenance info at startup, showing:
|
||||
* - Whether agent is resumed or newly created
|
||||
* - Where memory blocks came from (global/project/new)
|
||||
*
|
||||
* Layout matches ErrorMessage with a left column icon (grey circle)
|
||||
*/
|
||||
export const StatusMessage = memo(({ line }: { line: StatusLine }) => {
|
||||
const columns = useTerminalWidth();
|
||||
const contentWidth = Math.max(0, columns - 2);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{line.lines.map((text, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Static status lines never reorder
|
||||
<Box key={idx} flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text dimColor>{idx === 0 ? "●" : " "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text dimColor>{text}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
StatusMessage.displayName = "StatusMessage";
|
||||
@@ -44,6 +44,11 @@ export type Line =
|
||||
output: string;
|
||||
phase?: "running" | "finished";
|
||||
success?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "status";
|
||||
id: string;
|
||||
lines: string[]; // Multi-line status message with arrow formatting
|
||||
};
|
||||
|
||||
// Top-level state object for all streaming events
|
||||
|
||||
Reference in New Issue
Block a user