mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
feat(eve): refresh TUI startup card (#2562)
Signed-off-by: Colton Padden <colton.padden@vercel.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"eve": patch
|
||||
---
|
||||
|
||||
Replace the eve TUI's separate banner and text header with a startup card showing the installed version, active model, and the instructions, tools, skills, subagents, and schedules loaded by the agent.
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AgentInfoResult, AgentInfoToolEntry } from "#client/index.js";
|
||||
import type {
|
||||
AgentInfoInstructionsEntry,
|
||||
AgentInfoRemoteAgentEntry,
|
||||
AgentInfoResult,
|
||||
AgentInfoScheduleEntry,
|
||||
AgentInfoSkillEntry,
|
||||
AgentInfoToolEntry,
|
||||
} from "#client/index.js";
|
||||
import { stripAnsi } from "#cli/ui/terminal-text.js";
|
||||
import { createTestAgentInfoResult } from "#internal/testing/agent-info-fixture.js";
|
||||
|
||||
@@ -53,43 +60,120 @@ const INFO: AgentInfoResult = {
|
||||
describe("buildAgentHeader", () => {
|
||||
const theme = createTheme({ color: false, unicode: false });
|
||||
|
||||
it("renders the brand line with the agent name", () => {
|
||||
const lines = buildAgentHeader({ name: "agent-subagents", info: INFO, theme, width: 120 });
|
||||
it("renders the startup card", () => {
|
||||
const colorTheme = createTheme({ color: true, unicode: true });
|
||||
const info: AgentInfoResult = {
|
||||
...INFO,
|
||||
agent: {
|
||||
...INFO.agent,
|
||||
model: {
|
||||
id: "zai/glm-5.2",
|
||||
routing: { kind: "gateway", target: "zai" },
|
||||
endpoint: { kind: "gateway", connected: true, credential: "api-key" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const lines = buildAgentHeader({ info, theme: colorTheme, width: 120 });
|
||||
const plain = lines.map(stripAnsi);
|
||||
|
||||
expect(lines).toEqual([" eve agent-subagents"]);
|
||||
const card = plain.join("\n");
|
||||
const titleIndex = plain.findIndex((line) => line.includes("Weather Agent"));
|
||||
const logoIndex = plain.findIndex((line) => line.includes("⣿⣿⣿⣿⣿⣿⣿⣿⣿"));
|
||||
const modelIndex = plain.findIndex((line) => line.includes("model"));
|
||||
|
||||
expect(plain[0]).toBe(`╭${"─".repeat(66)}╮`);
|
||||
expect(plain[titleIndex]).toMatch(/^│ ☰eve \(v\d+\.\d+\.\d+\) +Weather Agent │$/u);
|
||||
expect(titleIndex).toBeLessThan(logoIndex);
|
||||
expect(logoIndex).toBeLessThan(modelIndex);
|
||||
expect(card).toContain("model zai/glm-5.2 via ai-gateway(api-key)");
|
||||
expect(card).toContain("instructions none");
|
||||
expect(card).toContain("agent get_weather");
|
||||
expect(card).toContain("eve bash");
|
||||
expect(card).toContain("skills none");
|
||||
expect(card).toContain("subagents none");
|
||||
expect(card).toContain("schedules none");
|
||||
expect(plain.at(-2)).toContain("schedules none");
|
||||
expect(lines[0]).toBe(colorTheme.colors.dim(plain[0]!));
|
||||
expect(lines[logoIndex]).toContain(colorTheme.colors.cyan("⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠏⣿⣿⣿⣿⣿⣿⣿⣿⣿"));
|
||||
expect(lines[modelIndex]).toContain(colorTheme.colors.dim("via "));
|
||||
});
|
||||
|
||||
it("renders just the brand line when info is unavailable", () => {
|
||||
expect(buildAgentHeader({ name: "weather-agent", theme, width: 120 })).toEqual([
|
||||
" eve weather-agent",
|
||||
]);
|
||||
});
|
||||
it("bounds every collection for large agents", () => {
|
||||
const source = (name: string) => ({
|
||||
logicalPath: `${name}.ts`,
|
||||
owner: { kind: "application" as const },
|
||||
sourceId: `${name}.ts`,
|
||||
sourceKind: "module" as const,
|
||||
});
|
||||
const instructions: AgentInfoInstructionsEntry[] = Array.from({ length: 12 }, (_, index) => ({
|
||||
...source(`instructions-${index}`),
|
||||
content: "Instructions.",
|
||||
name: `instructions-${index}`,
|
||||
role: "system",
|
||||
}));
|
||||
const skills: AgentInfoSkillEntry[] = Array.from({ length: 12 }, (_, index) => ({
|
||||
...source(`skill-${index}`),
|
||||
description: "Skill.",
|
||||
markdown: "# Skill",
|
||||
name: `skill-${index}`,
|
||||
}));
|
||||
const schedules: AgentInfoScheduleEntry[] = Array.from({ length: 12 }, (_, index) => ({
|
||||
...source(`schedule-${index}`),
|
||||
cron: "0 0 * * *",
|
||||
hasRun: true,
|
||||
name: `schedule-${index}`,
|
||||
}));
|
||||
const remoteAgents: AgentInfoRemoteAgentEntry[] = Array.from({ length: 12 }, (_, index) => ({
|
||||
...source(`subagent-${index}`),
|
||||
description: "Subagent.",
|
||||
name: `subagent-${index}`,
|
||||
nodeId: `remote-${index}`,
|
||||
parentNodeId: "__root__",
|
||||
}));
|
||||
const extensionTools = Array.from({ length: 6 }, (_, index): AgentInfoToolEntry => ({
|
||||
...AUTHORED_TOOL,
|
||||
logicalPath: `extension-${index}/tool.ts`,
|
||||
name: `extension_tool_${index}`,
|
||||
owner: {
|
||||
kind: "extension",
|
||||
namespace: `extension-${index}`,
|
||||
packageName: `@example/extension-${index}`,
|
||||
},
|
||||
sourceId: `extension-${index}/tool.ts`,
|
||||
}));
|
||||
const info: AgentInfoResult = {
|
||||
...INFO,
|
||||
instructions: { dynamic: [], static: instructions },
|
||||
remoteAgents: { entries: remoteAgents, total: remoteAgents.length },
|
||||
schedules,
|
||||
skills: { dynamic: [], static: skills },
|
||||
tools: { dynamic: [], static: [AUTHORED_TOOL, ...extensionTools, FRAMEWORK_TOOL] },
|
||||
};
|
||||
|
||||
it("renders the tip line for local sessions only", () => {
|
||||
const tip = AGENT_HEADER_TIPS[0]!;
|
||||
const local = buildAgentHeader({ name: "weather-agent", info: INFO, theme, width: 120, tip });
|
||||
expect(local).toEqual([" eve weather-agent", ` ${tip}`]);
|
||||
const card = buildAgentHeader({ info, theme, width: 120 }).join("\n");
|
||||
|
||||
const remote = buildAgentHeader({ name: "weather-agent", info: INFO, theme, width: 120 });
|
||||
expect(remote.join("\n")).not.toContain("/channels");
|
||||
for (const label of ["instructions", "skills", "subagents", "schedules"]) {
|
||||
expect(card.match(new RegExp(`${label}.*\\+\\d+ more`, "u"))).not.toBeNull();
|
||||
}
|
||||
expect(card).toContain(" +4 groups");
|
||||
expect(card).not.toContain(" eve bash");
|
||||
});
|
||||
|
||||
it("renders the /add tip with a blue command", () => {
|
||||
const colorTheme = createTheme({ color: true, unicode: false });
|
||||
const tip = AGENT_HEADER_TIPS.find((candidate) => candidate.includes("/add"));
|
||||
|
||||
expect(tip).toBe("Use /add to install integrations from the registry.");
|
||||
expect(tip).toBe("Use the /add command to install an integration.");
|
||||
if (tip === undefined) return;
|
||||
|
||||
const line = buildAgentHeader({
|
||||
name: "weather-agent",
|
||||
info: INFO,
|
||||
theme: colorTheme,
|
||||
width: 120,
|
||||
tip,
|
||||
}).at(-1);
|
||||
|
||||
expect(stripAnsi(line ?? "")).toBe(` ${tip}`);
|
||||
expect(stripAnsi(line ?? "")).toBe(` Tip: ${tip}`);
|
||||
expect(line).toContain(colorTheme.colors.blue("/add"));
|
||||
});
|
||||
|
||||
@@ -98,7 +182,7 @@ describe("buildAgentHeader", () => {
|
||||
...INFO,
|
||||
diagnostics: { discoveryErrors: 1, discoveryWarnings: 2 },
|
||||
};
|
||||
const lines = buildAgentHeader({ name: "weather-agent", info, theme, width: 120 });
|
||||
const lines = buildAgentHeader({ info, theme, width: 120 });
|
||||
|
||||
expect(lines.some((line) => line.includes("1 error"))).toBe(true);
|
||||
expect(lines.some((line) => line.includes("2 warnings"))).toBe(true);
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
/**
|
||||
* Builds the startup header the dev TUI commits to scrollback before the
|
||||
* first prompt: one `eve <agent name>` brand line, a discovery-diagnostics
|
||||
* line when the compiler reported problems, and a rotating tip for local
|
||||
* sessions. The resolved model is not repeated here — it lives on the
|
||||
* persistent status line at the bottom.
|
||||
*/
|
||||
/** Builds the startup card the dev TUI commits before the first prompt. */
|
||||
|
||||
import type { AgentInfoResult } from "#client/index.js";
|
||||
import { resolveInstalledPackageInfo } from "#internal/application/package.js";
|
||||
import { clipVisible, visibleLength } from "#cli/ui/terminal-text.js";
|
||||
import { isPromptControlCommand } from "./prompt-commands.js";
|
||||
import type { Theme } from "./theme.js";
|
||||
import { truncate } from "./tool-format.js";
|
||||
|
||||
export interface AgentHeaderInput {
|
||||
/** Resolved display name (e.g. "weather-agent"). */
|
||||
name: string;
|
||||
/** Resolved display name used when agent inspection is unavailable. */
|
||||
name?: string;
|
||||
/** Agent inspection payload, or `undefined` when it could not be fetched. */
|
||||
info?: AgentInfoResult;
|
||||
theme: Theme;
|
||||
/** Available terminal width. */
|
||||
width: number;
|
||||
/** Message-of-the-day line rendered under the brand line, when present. */
|
||||
/** Message-of-the-day line rendered below the startup card, when present. */
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
@@ -28,11 +23,21 @@ export interface AgentHeaderInput {
|
||||
* slash commands, so callers only attach a tip to local `eve dev` sessions.
|
||||
*/
|
||||
export const AGENT_HEADER_TIPS: readonly string[] = [
|
||||
"Use /add to install integrations from the registry.",
|
||||
"Use /deploy to see your agent go live.",
|
||||
"Type /help to see every command.",
|
||||
"Use the /add command to install an integration.",
|
||||
"Use the /deploy command to deploy your agent.",
|
||||
"Use the /help command to see every command.",
|
||||
];
|
||||
|
||||
const MAX_TOOL_GROUPS = 4;
|
||||
|
||||
const EVE_LOGO = [
|
||||
"⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠏⣿⣿⣿⣿⣿⣿⣿⣿⣿",
|
||||
" ⠇⣿⠏",
|
||||
"⣿⣿⣿⣿⣿⣿⠇ ⠇⣿⠏ ⠇⣿⣿⣿⣿⣿",
|
||||
" ⠃⣿⠏",
|
||||
"⣿⣿⣿⣿⣿⣿⠏ ⠃⣿⠏ ⠇⣿⣿⣿⣿⣿⣿⣿",
|
||||
] as const;
|
||||
|
||||
/** Picks one tip; `random` is a test seam over Math.random. */
|
||||
export function pickAgentHeaderTip(random: () => number = Math.random): string {
|
||||
const index = Math.min(
|
||||
@@ -42,17 +47,81 @@ export function pickAgentHeaderTip(random: () => number = Math.random): string {
|
||||
return AGENT_HEADER_TIPS[index]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the styled rows of the startup header (no trailing blank line is
|
||||
* added by callers other than the one separating it from the transcript).
|
||||
*/
|
||||
/** Returns the styled rows of the startup card and optional tip. */
|
||||
export function buildAgentHeader(input: AgentHeaderInput): string[] {
|
||||
const { theme, info, name, width } = input;
|
||||
const { theme, info, width } = input;
|
||||
const c = theme.colors;
|
||||
const version = resolveInstalledPackageInfo().version;
|
||||
// Leave the terminal's final column untouched so terminals that wrap on a
|
||||
// write there do not add an untracked row beneath the live region.
|
||||
const cardWidth = Math.min(68, Math.max(0, width - 1));
|
||||
|
||||
const lines: string[] = [];
|
||||
const brand = c.bold("eve");
|
||||
lines.push(` ${brand} ${c.dim(truncate(name, Math.max(8, width - 8)))}`);
|
||||
const brand = `${c.dim("☰")}${c.bold("eve")} ${c.dim(`(v${version})`)}`;
|
||||
if (cardWidth < 4) return [clipVisible(brand, width)];
|
||||
|
||||
const horizontal = theme.unicode ? "─" : "-";
|
||||
const vertical = theme.unicode ? "│" : "|";
|
||||
const topLeft = theme.unicode ? "╭" : "+";
|
||||
const topRight = theme.unicode ? "╮" : "+";
|
||||
const bottomLeft = theme.unicode ? "╰" : "+";
|
||||
const bottomRight = theme.unicode ? "╯" : "+";
|
||||
const innerWidth = cardWidth - 2;
|
||||
const border = horizontal.repeat(innerWidth);
|
||||
const row = (text = "", ambiguousWidth = 0): string => {
|
||||
const available = Math.max(0, innerWidth - 2);
|
||||
const body = clipVisible(text, available);
|
||||
const padding = Math.max(0, available - visibleLength(body) - ambiguousWidth);
|
||||
return `${c.dim(vertical)} ${body}${" ".repeat(padding)} ${c.dim(vertical)}`;
|
||||
};
|
||||
|
||||
const agentName = info?.agent.name ?? input.name;
|
||||
const title =
|
||||
agentName === undefined ? brand : spreadRow(brand, c.bold(agentName), innerWidth - 2, 1);
|
||||
const lines = [c.dim(`${topLeft}${border}${topRight}`)];
|
||||
// U+2630 is East Asian Ambiguous and renders as two cells in some
|
||||
// terminals, so reserve its second cell explicitly inside the card.
|
||||
lines.push(row(title, 1), row());
|
||||
const logoWidth = Math.max(...EVE_LOGO.map((line) => visibleLength(line)));
|
||||
if (theme.unicode && innerWidth - 2 >= logoWidth) {
|
||||
lines.push(
|
||||
...EVE_LOGO.map((line) => row(centerLogoLine(line, logoWidth, innerWidth - 2, theme))),
|
||||
row(),
|
||||
);
|
||||
}
|
||||
|
||||
const detail = (label: string, value: string): string =>
|
||||
row(`${c.dim(label.padEnd(14))}${value}`);
|
||||
const model = formatHeaderModel(info?.agent.model, theme);
|
||||
if (model !== undefined) lines.push(detail("model", model));
|
||||
if (info !== undefined) {
|
||||
lines.push(detail("instructions", formatInstructions(info, innerWidth - 18)));
|
||||
lines.push(row());
|
||||
const toolGroups = groupTools(info);
|
||||
if (toolGroups.length === 0) {
|
||||
lines.push(detail("tools", "none"));
|
||||
} else {
|
||||
lines.push(detail("tools", ""));
|
||||
for (const group of toolGroups.slice(0, MAX_TOOL_GROUPS)) {
|
||||
lines.push(detail(` ${group.label}`, fitNames(group.names, innerWidth - 18)));
|
||||
}
|
||||
const omittedGroups = toolGroups.length - MAX_TOOL_GROUPS;
|
||||
if (omittedGroups > 0) {
|
||||
lines.push(detail(` +${omittedGroups} ${pluralize(omittedGroups, "group")}`, ""));
|
||||
}
|
||||
}
|
||||
lines.push(detail("skills", fitNames(skillNames(info), innerWidth - 18)));
|
||||
lines.push(detail("subagents", fitNames(subagentNames(info), innerWidth - 18)));
|
||||
lines.push(
|
||||
detail(
|
||||
"schedules",
|
||||
fitNames(
|
||||
info.schedules.map((schedule) => schedule.name),
|
||||
innerWidth - 18,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
lines.push(c.dim(`${bottomLeft}${border}${bottomRight}`));
|
||||
|
||||
if (info && (info.diagnostics.discoveryErrors > 0 || info.diagnostics.discoveryWarnings > 0)) {
|
||||
const parts: string[] = [];
|
||||
@@ -72,25 +141,129 @@ export function buildAgentHeader(input: AgentHeaderInput): string[] {
|
||||
),
|
||||
);
|
||||
}
|
||||
lines.push(` ${c.dim(theme.glyph.warning)} ${parts.join(c.dim(" · "))}`);
|
||||
lines.push("", ` ${c.dim(theme.glyph.warning)} ${parts.join(c.dim(" · "))}`);
|
||||
}
|
||||
|
||||
if (input.tip !== undefined) {
|
||||
lines.push(` ${renderTip(input.tip, Math.max(8, width - 2), theme)}`);
|
||||
lines.push("", ` ${c.bold("Tip:")} ${renderTip(input.tip, Math.max(8, width - 7), theme)}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function centerLogoLine(text: string, logoWidth: number, width: number, theme: Theme): string {
|
||||
const padding = Math.max(0, Math.floor((width - logoWidth) / 2));
|
||||
return `${" ".repeat(padding)}${theme.colors.cyan(text)}`;
|
||||
}
|
||||
|
||||
function spreadRow(left: string, right: string, width: number, ambiguousWidth: number): string {
|
||||
const available = Math.max(1, width - ambiguousWidth);
|
||||
const clippedLeft = clipVisible(left, Math.max(1, available - visibleLength(right) - 1));
|
||||
const clippedRight = clipVisible(right, Math.max(1, available - visibleLength(clippedLeft) - 1));
|
||||
const gap = Math.max(1, available - visibleLength(clippedLeft) - visibleLength(clippedRight));
|
||||
return `${clippedLeft}${" ".repeat(gap)}${clippedRight}`;
|
||||
}
|
||||
|
||||
function groupTools(info: AgentInfoResult): Array<{ label: string; names: string[] }> {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const tool of info.tools.static) {
|
||||
const label =
|
||||
tool.owner.kind === "application"
|
||||
? "agent"
|
||||
: tool.owner.kind === "framework"
|
||||
? "eve"
|
||||
: tool.owner.namespace;
|
||||
const names = groups.get(label) ?? [];
|
||||
names.push(tool.name);
|
||||
groups.set(label, names);
|
||||
}
|
||||
if (info.tools.dynamic.length > 0) {
|
||||
groups.set(
|
||||
"dynamic",
|
||||
info.tools.dynamic.map((resolver) => resolver.slug),
|
||||
);
|
||||
}
|
||||
const priority = (label: string): number => {
|
||||
if (label === "agent") return 0;
|
||||
if (label === "dynamic") return 2;
|
||||
if (label === "eve") return 3;
|
||||
return 1;
|
||||
};
|
||||
return [...groups]
|
||||
.map(([label, names]) => ({ label, names: names.sort() }))
|
||||
.sort((a, b) => priority(a.label) - priority(b.label) || a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
function skillNames(info: AgentInfoResult): string[] {
|
||||
return [
|
||||
...info.skills.static.map((skill) => skill.name),
|
||||
...info.skills.dynamic.map((resolver) => `${resolver.slug} (dynamic)`),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function subagentNames(info: AgentInfoResult): string[] {
|
||||
return [...info.subagents.local, ...info.remoteAgents.entries]
|
||||
.map((subagent) => subagent.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function formatInstructions(info: AgentInfoResult, width: number): string {
|
||||
const names = [
|
||||
...info.instructions.static.map(
|
||||
(instructions) => `${instructions.logicalPath} (${instructions.role})`,
|
||||
),
|
||||
...info.instructions.dynamic.map((resolver) => `${resolver.slug} (dynamic)`),
|
||||
];
|
||||
return fitNames(names, width);
|
||||
}
|
||||
|
||||
function fitNames(names: readonly string[], width: number): string {
|
||||
if (names.length === 0) return "none";
|
||||
for (let count = names.length; count > 0; count -= 1) {
|
||||
const hidden = names.length - count;
|
||||
const value = `${names.slice(0, count).join(", ")}${hidden > 0 ? `, +${hidden} more` : ""}`;
|
||||
if (visibleLength(value) <= width) return value;
|
||||
}
|
||||
return clipVisible(names[0]!, width);
|
||||
}
|
||||
|
||||
function formatHeaderModel(
|
||||
model: AgentInfoResult["agent"]["model"] | undefined,
|
||||
theme: Theme,
|
||||
): string | undefined {
|
||||
if (model?.id === undefined) return undefined;
|
||||
const endpoint = model.endpoint;
|
||||
if (endpoint === undefined) return model.id;
|
||||
|
||||
const via = theme.colors.dim("via ");
|
||||
switch (endpoint.kind) {
|
||||
case "external":
|
||||
return `${model.id} ${via}${endpoint.provider}`;
|
||||
case "chatgpt":
|
||||
return `${model.id} ${via}chatgpt-sub`;
|
||||
case "gateway":
|
||||
return endpoint.connected
|
||||
? `${model.id} ${via}ai-gateway(${endpoint.credential})`
|
||||
: `${model.id} ${via}ai-gateway(not connected)`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTip(tip: string, width: number, theme: Theme): string {
|
||||
return truncate(tip, width)
|
||||
.split(/(\/[a-z:-]+)/u)
|
||||
.map((part) =>
|
||||
isPromptControlCommand(part) ? theme.colors.blue(part) : theme.colors.dim(part),
|
||||
)
|
||||
.join("");
|
||||
return clipVisible(
|
||||
tip
|
||||
.split(/(\/[a-z:-]+)/u)
|
||||
.map((part) =>
|
||||
isPromptControlCommand(part) ? theme.colors.blue(part) : theme.colors.dim(part),
|
||||
)
|
||||
.join(""),
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
function plural(count: number): string {
|
||||
return count === 1 ? "" : "s";
|
||||
}
|
||||
|
||||
function pluralize(count: number, noun: string): string {
|
||||
return `${noun}${plural(count)}`;
|
||||
}
|
||||
|
||||
@@ -226,16 +226,15 @@ export type AgentTUIAgentHeader = {
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
info?: AgentInfoResult;
|
||||
/** Message-of-the-day line shown under the brand line (local sessions only). */
|
||||
/** Message-of-the-day line shown below the startup card (local sessions only). */
|
||||
tip?: string;
|
||||
};
|
||||
|
||||
export type AgentTUIRenderer = {
|
||||
/**
|
||||
* Commits a startup header describing the connected agent (brand mark,
|
||||
* model, instructions, tools, skills, subagents) to the transcript before
|
||||
* the first prompt, and refreshes it after local dev artifact changes.
|
||||
* Optional — renderers without a header simply skip it.
|
||||
* Commits the startup card to the transcript before the first prompt and
|
||||
* refreshes it after local dev artifact changes. Optional — renderers
|
||||
* without a header simply skip it.
|
||||
*/
|
||||
renderAgentHeader?(header: AgentTUIAgentHeader): void;
|
||||
/**
|
||||
|
||||
@@ -171,24 +171,24 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
expect(process.listeners("exit")).toEqual(before);
|
||||
});
|
||||
|
||||
it("renders the brand line with the agent name and a tip", () => {
|
||||
it("commits the startup card before the prompt", () => {
|
||||
const { screen, renderer } = makeRenderer();
|
||||
renderer.renderAgentHeader({
|
||||
name: "Weather Agent",
|
||||
serverUrl: "http://localhost:3000",
|
||||
info: agentInfoWithModel("gpt-5"),
|
||||
tip: "Use eve add to install integrations from the registry.",
|
||||
info: agentInfoWithModel("gpt-5", {
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
credential: "api-key",
|
||||
}),
|
||||
tip: "Use the /deploy command to deploy your agent.",
|
||||
});
|
||||
renderer.shutdown();
|
||||
|
||||
const snapshot = screen.snapshot();
|
||||
expect(snapshot).toContain("eve Weather Agent");
|
||||
expect(snapshot).toContain("Use eve add to install integrations from the registry.");
|
||||
// The model lives on the status line, not the header; the old config
|
||||
// rows and key hints are gone.
|
||||
expect(snapshot).not.toContain("gpt-5");
|
||||
expect(snapshot).toMatch(/☰eve \(v\d+\.\d+\.\d+\).*Weather Agent/u);
|
||||
expect(snapshot).toContain("Tip: Use the /deploy command to deploy your agent.");
|
||||
expect(snapshot).not.toContain("http://localhost:3000");
|
||||
expect(snapshot).not.toContain("Type to chat");
|
||||
});
|
||||
|
||||
it("refreshes the committed agent header with the latest model", async () => {
|
||||
@@ -215,7 +215,9 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
|
||||
const snapshot = screen.snapshot();
|
||||
expect(snapshot).toContain("new-model");
|
||||
expect(snapshot).not.toContain("old-model");
|
||||
// Header refreshes append to scrollback, preserving the prior startup card.
|
||||
expect(snapshot).toContain("old-model");
|
||||
expect(snapshot.lastIndexOf("new-model")).toBeGreaterThan(snapshot.lastIndexOf("old-model"));
|
||||
expect(snapshot).toContain("hello");
|
||||
expect(snapshot).toContain("still here");
|
||||
renderer.shutdown();
|
||||
@@ -3586,25 +3588,6 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
expect(screen.snapshot()).toContain("Session ended — started a new session.");
|
||||
});
|
||||
|
||||
it("refreshing the agent header preserves committed transcript and scrollback", () => {
|
||||
const { screen, renderer } = makeRenderer();
|
||||
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
|
||||
renderer.renderNotice("previous transcript");
|
||||
|
||||
// Dev HMR refresh: a fresh header is committed beneath the transcript —
|
||||
// nothing is cleared or replayed.
|
||||
renderer.renderAgentHeader({ name: "Weather Agent v2", serverUrl: "http://localhost:3000" });
|
||||
renderer.shutdown();
|
||||
|
||||
const snapshot = screen.snapshot();
|
||||
expect(snapshot).toContain("previous transcript");
|
||||
expect(snapshot).toContain("Weather Agent v2");
|
||||
// The refreshed header lands after the prior transcript, not on a wiped screen.
|
||||
expect(snapshot.indexOf("Weather Agent v2")).toBeGreaterThan(
|
||||
snapshot.indexOf("previous transcript"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not repeat the banner when a source reload re-sends an unchanged header", () => {
|
||||
const { screen, renderer } = makeRenderer();
|
||||
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
|
||||
@@ -3615,7 +3598,7 @@ describe("TerminalRenderer (inline scrollback)", () => {
|
||||
renderer.renderAgentHeader({ name: "Weather Agent", serverUrl: "http://localhost:3000" });
|
||||
renderer.shutdown();
|
||||
|
||||
expect(countOccurrences(screen.snapshot(), "Weather Agent")).toBe(1);
|
||||
expect(countOccurrences(screen.snapshot(), "☰eve (v")).toBe(1);
|
||||
});
|
||||
|
||||
it("reset clears committed transcript rows", () => {
|
||||
|
||||
@@ -295,7 +295,7 @@ export type AgentHeaderOptions = {
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
info?: AgentInfoResult;
|
||||
/** Message-of-the-day line under the brand line (local sessions only). */
|
||||
/** Message-of-the-day line below the startup card (local sessions only). */
|
||||
tip?: string;
|
||||
};
|
||||
|
||||
@@ -629,8 +629,8 @@ export class TerminalRenderer implements AgentTUIRenderer {
|
||||
/**
|
||||
* Commits the startup agent header (brand mark + resolved configuration) to
|
||||
* scrollback before the first prompt. Later calls (dev HMR refreshing fields
|
||||
* such as the agent name) commit a fresh header beneath the existing
|
||||
* transcript only when the rendered header actually changed — every source
|
||||
* such as the model) commit a fresh header beneath the existing transcript
|
||||
* only when the rendered header actually changed — every source
|
||||
* reload re-sends it, and an identical banner repeated per reload is noise.
|
||||
* Committed scrollback is never cleared or replayed.
|
||||
*/
|
||||
|
||||
@@ -828,6 +828,25 @@ describe("eve acp", () => {
|
||||
});
|
||||
|
||||
describe("eve dev boot progress", () => {
|
||||
it("leaves the interactive startup banner to the TUI", async () => {
|
||||
const logger = { error: vi.fn(), log: vi.fn() };
|
||||
const startHost = vi.fn(() => ({
|
||||
start: async () => ({
|
||||
kind: "started" as const,
|
||||
appRoot: "/canonical/app",
|
||||
url: "http://127.0.0.1:2000",
|
||||
}),
|
||||
close: async () => {},
|
||||
}));
|
||||
|
||||
await withInteractiveTerminal(() =>
|
||||
runCli(["dev"], logger, { runDevelopmentTui: vi.fn(async () => {}), startHost }),
|
||||
);
|
||||
|
||||
expect(logger.log).not.toHaveBeenCalledWith(expect.stringContaining("☰eve"));
|
||||
expect(logger.log).not.toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("passes one reporter through local startup and clears the row on failure", async () => {
|
||||
const writes: string[] = [];
|
||||
const close = vi.fn(async () => {});
|
||||
|
||||
@@ -167,9 +167,7 @@ function createCliProgram(
|
||||
.exitOverride()
|
||||
.hook("preAction", (_program, actionCommand) => {
|
||||
const { json } = actionCommand.opts<{ json?: boolean }>();
|
||||
if (["info", "dev", "init"].includes(actionCommand.name()) && !json) {
|
||||
logger.log(eveCliBanner());
|
||||
}
|
||||
if (["info", "init"].includes(actionCommand.name()) && !json) logger.log(eveCliBanner());
|
||||
})
|
||||
.configureOutput({
|
||||
writeErr: (message) => {
|
||||
@@ -387,6 +385,7 @@ function createCliProgram(
|
||||
const remoteServerUrl = remoteTarget?.serverUrl;
|
||||
const interactive = hasInteractiveTerminal();
|
||||
const mode = resolveDevUiMode({ options, interactive });
|
||||
if (mode === "headless") logger.log(eveCliBanner());
|
||||
if (options.input !== undefined && mode === "headless") {
|
||||
throw new InvalidArgumentError("--input requires the interactive UI.");
|
||||
}
|
||||
@@ -465,7 +464,6 @@ function createCliProgram(
|
||||
logger.log(
|
||||
`↗ ${existingLocalDevelopmentServer ? "local" : "remote"} mode targeting ${theme.info(new URL(remoteServerUrl).host)}`,
|
||||
);
|
||||
|
||||
if (mode === "headless") {
|
||||
logger.log(
|
||||
renderCliTaggedLine(theme, {
|
||||
@@ -487,7 +485,6 @@ function createCliProgram(
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "tui") logger.log("");
|
||||
const buildProgress = mode === "tui" ? startCliLiveRow(logger) : undefined;
|
||||
const onBootProgress = createDevBootProgressReporter(buildProgress);
|
||||
buildProgress?.update("Building your agent");
|
||||
|
||||
Reference in New Issue
Block a user