diff --git a/src/components/ui/spinner/Spinner.tsx b/src/components/ui/spinner/Spinner.tsx index 6c232a51d..dfa79447d 100644 --- a/src/components/ui/spinner/Spinner.tsx +++ b/src/components/ui/spinner/Spinner.tsx @@ -32,7 +32,10 @@ export const Spinner: React.FC = ({ return ( - {frames[frame]} + {/* Keep the frame column when the label is wider than the row. */} + + {frames[frame]} + {label ? {label} : null} ); diff --git a/src/components/ui/task-list/TaskList.test.tsx b/src/components/ui/task-list/TaskList.test.tsx index 91531d4f7..efe10f483 100644 --- a/src/components/ui/task-list/TaskList.test.tsx +++ b/src/components/ui/task-list/TaskList.test.tsx @@ -75,4 +75,25 @@ describe("TaskList", () => { expect(tailLine).toContain("…"); instance.unmount(); }); + + // A title with no break opportunity (a Windows temp path) is wider than the + // row; Ink's default shrink used to give the glyph column to the title. + test("keeps the glyph and spinner when an unbreakable title exceeds the width", () => { + const title = "Reading 'C:\\Users\\Admin\\AppData\\Local\\Temp\\agentcore-x\\agentcore.json'"; + const instance = render(<>); + Object.defineProperty(instance.stdout, "columns", { configurable: true, value: 40 }); + instance.rerender( + , + ); + + const lines = (instance.lastFrame() ?? "").split("\n"); + expect(lines[0]).toMatch(/^✓ Reading/); + expect(lines.some((line) => /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Reading/.test(line))).toBe(true); + instance.unmount(); + }); }); diff --git a/src/components/ui/task-list/TaskList.tsx b/src/components/ui/task-list/TaskList.tsx index a35d1bb93..56ad5694f 100644 --- a/src/components/ui/task-list/TaskList.tsx +++ b/src/components/ui/task-list/TaskList.tsx @@ -49,9 +49,12 @@ export const TaskList: React.FC = ({ ) : ( - - {task.state === "done" ? glyphs.done : glyphs.failed} - + {/* Keep the glyph column when the title is wider than the row. */} + + + {task.state === "done" ? glyphs.done : glyphs.failed} + + {task.title} )} diff --git a/src/handlers/project/add/memory/index.test.ts b/src/handlers/project/add/memory/index.test.ts index 509cd709b..e218c020d 100644 --- a/src/handlers/project/add/memory/index.test.ts +++ b/src/handlers/project/add/memory/index.test.ts @@ -32,8 +32,17 @@ afterEach(async () => { ); }); -async function run(args: string[], opts?: { core?: TestCoreClient }) { - const io = testIO(); +// Ink writes cursor/erase sequences around each frame; the TTY assertions +// below care about frame text, not terminal control. Built without a +// control-char literal so lint stays quiet. +const ANSI_SEQUENCE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[A-Za-z]`, "g"); + +function stripAnsi(text: string): string { + return text.replace(ANSI_SEQUENCE, ""); +} + +async function run(args: string[], opts?: { core?: TestCoreClient; isTTY?: boolean }) { + const io = testIO({ isTTY: opts?.isTTY }); const core = opts?.core ?? new TestCoreClient(); const root = createRootHandler(core, { io: io.io, @@ -66,6 +75,33 @@ describe("project add memory", () => { expect(io.stderr()).not.toContain("added memory"); }); + // The same progress driver create, build, and deploy use: a TTY gets the live + // step list with every step marked done, and the success line follows it. + test("renders a live step list on a TTY", async () => { + await inProject(); + const { io } = await run(["add", "memory", "--name", "customer_memory"], { isTTY: true }); + + const frames = stripAnsi(io.stderr()); + expect(frames).toContain("✓ Reading project spec file"); + expect(frames).toContain("✓ Updating project spec file"); + expect(frames).toContain("added memory 'customer_memory' to 'TestProject'"); + expect(io.stdout()).toBe(""); + }); + + test("--json on a TTY keeps the plain step lines so no ANSI reaches stderr", async () => { + await inProject(); + const { io } = await run(["add", "memory", "--name", "customer_memory", "--json"], { + isTTY: true, + }); + + expect(io.stderr()).not.toContain(String.fromCharCode(0x1b)); + expect(io.stderr()).toContain("Reading project spec file"); + expect(JSON.parse(io.stdout())).toMatchObject({ + operation: "add", + resource: { type: "memory", name: "customer_memory" }, + }); + }); + /** Verify the flag -> agentcore.json memories[] entry for each flag. */ test.each<[string, string[], Record]>([ [ diff --git a/src/handlers/project/add/shared.ts b/src/handlers/project/add/shared.ts index 800e728a9..b1f211dc4 100644 --- a/src/handlers/project/add/shared.ts +++ b/src/handlers/project/add/shared.ts @@ -1,5 +1,6 @@ import type { Context } from "../../../router"; import { runWithProgress } from "../../../tui/progress"; +import { JsonKey } from "../../keys"; import { renderResult } from "../../utils"; import { projectMutationResource, @@ -23,11 +24,12 @@ export async function addProjectResource( humanSuccessMessage: string, options: AddProjectResourceResultOptions = {}, ): Promise { + // Same driver as create, build, and deploy: a live step list in a TTY, and + // plain line-per-step output when stderr is not a TTY or --json wants no ANSI + // on it. const updatedProject = await runWithProgress(config.projectManager.addResource(project, input), { io: config.io, - // Project add commands historically print plain progress lines even on a - // TTY. Keep that behavior while still collecting the generator result. - interactive: false, + interactive: ctx.require(JsonKey) ? false : undefined, }); renderResult(