diff --git a/.changeset/neat-cats-greet.md b/.changeset/neat-cats-greet.md new file mode 100644 index 000000000..cda453247 --- /dev/null +++ b/.changeset/neat-cats-greet.md @@ -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. diff --git a/packages/eve/src/cli/dev/tui/agent-header.test.ts b/packages/eve/src/cli/dev/tui/agent-header.test.ts index 85214a4b6..e02d3355e 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.test.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.test.ts @@ -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); diff --git a/packages/eve/src/cli/dev/tui/agent-header.ts b/packages/eve/src/cli/dev/tui/agent-header.ts index 98b068770..3ec303477 100644 --- a/packages/eve/src/cli/dev/tui/agent-header.ts +++ b/packages/eve/src/cli/dev/tui/agent-header.ts @@ -1,25 +1,20 @@ -/** - * Builds the startup header the dev TUI commits to scrollback before the - * first prompt: one `eve ` 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(); + 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)}`; +} diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index eb0a69d06..c1f43f88e 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -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; /** diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index 3033b487d..660a8cc15 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -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", () => { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 558f06c35..750cf6a6a 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -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. */ diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index d8f2250e4..3b8efa3b9 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -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 () => {}); diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 4ff9841a1..cc3a5cfa9 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -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");