diff --git a/src/components/ui/task-list/TaskList.test.tsx b/src/components/ui/task-list/TaskList.test.tsx index 91531d4f7..c66ddf9da 100644 --- a/src/components/ui/task-list/TaskList.test.tsx +++ b/src/components/ui/task-list/TaskList.test.tsx @@ -55,6 +55,29 @@ describe("TaskList", () => { expect(frame).toContain("│ three"); }); + test("keeps a long title on one line without losing its glyph", () => { + const instance = render(<>); + Object.defineProperty(instance.stdout, "columns", { configurable: true, value: 40 }); + // Left to wrap, the title flex-shrinks the glyph column away entirely. + instance.rerender( + , + ); + + const lines = (instance.lastFrame() ?? "").split("\n").filter((line) => line.trim()); + expect(lines).toHaveLength(1); + expect(lines[0]).toStartWith("✓ Updating project spec file"); + expect(lines[0]).toContain("…"); + instance.unmount(); + }); + test("truncates tail lines to the terminal width", () => { const instance = render(<>); Object.defineProperty(instance.stdout, "columns", { configurable: true, value: 24 }); diff --git a/src/components/ui/task-list/TaskList.tsx b/src/components/ui/task-list/TaskList.tsx index db956c594..946d7fcda 100644 --- a/src/components/ui/task-list/TaskList.tsx +++ b/src/components/ui/task-list/TaskList.tsx @@ -40,19 +40,23 @@ export const TaskList: React.FC = ({ // `||`, not `??`: a pty can report 0 columns, which would truncate every // tail line to nothing. Match Ink's own layout fallback of 80. const columns = stdout?.columns || 80; + // One line per step: a title left to wrap flex-shrinks the 1-char glyph + // column to nothing, so the ✓/✕/spinner disappears. The glyph and its + // trailing space take 2 of the row's columns. + const title = (task: Task) => cliTruncate(task.title, Math.max(columns - 2, 3)); return ( {tasks.map((task, index) => ( {task.state === "running" ? ( - + ) : ( {task.state === "done" ? "✓" : "✕"} - {task.title} + {title(task)} )} {task.state !== "done" && diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 674b84be7..bb785d22f 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -279,6 +279,29 @@ describe("FsProjectManager.create", () => { ]); }); + test("streams subprocess output as line-buffered output events", async () => { + await inTempDirectory(); + const subject = new FsProjectManager({ + logger: createSilentLogger(), + // The chunk boundary splits a line, so a chunk-per-event bridge would + // leak the fragments "vulnerabilit" / "ies". + runner: async (_command, { onOutput }) => { + onOutput?.("added 12 packages\nfound 0 vulnerabilit"); + onOutput?.("ies\n"); + }, + checkTool: async () => {}, + identity: new TestIdentityClient(), + }); + + const { events } = await runCreate(subject, { + name: "example", + scaffoldRuntimeInput: AGENT_PYTHON, + }); + + expect(events).toContainEqual({ type: "output", line: "added 12 packages" }); + expect(events).toContainEqual({ type: "output", line: "found 0 vulnerabilities" }); + }); + test("skipInstall skips npm install and uv sync", async () => { const directory = await inTempDirectory(); const { manager: subject, commands } = manager(); diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index 4b23a9bca..5f36f73c2 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -25,7 +25,8 @@ import { type HarnessModelProvider, } from "../../../projectSchemas/harness"; import { InputValidationError } from "../../../errors"; -import { parseJsonFlag } from "../../utils"; +import { JsonRendererKey } from "../../../tui"; +import { parseJsonFlag, renderJsonError } from "../../utils"; import { DEFAULT_HARNESS_MODEL } from "../add/harness"; import type { CoreBedrockAgentImporter } from "../../../core/project/bedrockAgentImport"; import { importScaffoldRuntimeInput, resolveImportBedrockAgentInput } from "../importBedrockAgent"; @@ -196,6 +197,7 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = flag("skip-git", "skip initializing a git repository", z.boolean().default(false)), ], handle: async (ctx, flags) => { + const jsonOutput = ctx.require(JsonKey); const name = flags["name"]; if (name === undefined) { throw new InputValidationError("required option '--name ' not specified"); @@ -305,13 +307,20 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = // Same driver as build and deploy: a live step list in a TTY, and the previous plain // line-per-step output when stderr is not a TTY or --json wants no ANSI on it. - await runWithProgress(config.projectManager.create(createInput), { - io: config.io, - interactive: ctx.require(JsonKey) ? false : undefined, - }); + try { + await runWithProgress(config.projectManager.create(createInput), { + io: config.io, + interactive: jsonOutput ? false : undefined, + }); + } catch (error) { + if (jsonOutput) renderJsonError(ctx, error); + throw error; + } - config.io.stderr.write(`Created project '${name}' in ./${name}\n`); + const message = `Created project '${name}' in ./${name}`; + config.io.stderr.write(`${message}\n`); config.io.stderr.write(`To deploy it: cd ${name} && agentcore project deploy\n`); + if (jsonOutput) ctx.require(JsonRendererKey).renderJson({ message }); }, }); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 9a1a1ee61..c7c136514 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -387,6 +387,40 @@ describe("project create", () => { expect(io.stderr()).toContain("Syncing Python dependencies with uv"); expect(io.stderr()).toContain("Initializing git repository"); expect(io.stderr()).toContain("Created project 'MyAgent' in ./MyAgent"); + expect(io.stdout()).toBe(""); + }); + + test("renders the success message as JSON with --json", async () => { + await inTempDirectory(); + const { io } = await run([ + "create", + "--name", + "MyAgent", + "--skip-install", + "--skip-git", + "--json", + ]); + + expect(JSON.parse(io.stdout())).toEqual({ message: "Created project 'MyAgent' in ./MyAgent" }); + }); + + test("renders a create failure as JSON without changing the thrown error", async () => { + await inTempDirectory(); + await run(["create", "--name", "MyAgent", "--skip-install", "--skip-git"]); + + // The run helper builds io after routing, so drive the failing route with + // its own io to read what the command wrote before rejecting. + const io = testIO(); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + const args = ["create", "--name", "MyAgent", "--skip-install", "--skip-git", "--json"]; + + await expect(root.route(["node", "agentcore", "project", ...args])).rejects.toThrow(/MyAgent/); + + expect(JSON.parse(io.stdout())).toEqual({ error: expect.stringContaining("MyAgent") }); }); test("--skip-install and --skip-git run no commands", async () => {