diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 0eb49e656..aaecbd5b8 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -175,6 +175,17 @@ Pick the conversation-card and shell-chrome style in `/gentle:customize` → **C - `float` applies to tool, Code and 🌹 cards, Agent result and stale cards, the review preflight reminder, the dev-binary notice, and the Agents, Todos and Status panels. The prompt and fullscreen header/footer use their specialized float chrome described above. The regular-mode one-line Status bar is unchanged. - A theme without a tool background, or a card narrower than 10 columns, falls back to `neon`. A malformed `card-style.json` reads as `float` and the panel refuses to overwrite it. +### Card content + +Pick how much a collapsed quiet-tool card shows in `/gentle:customize` → **Cards** → **Card content**. The choice is saved in `card-content.json` in the Gentle Pi config home; a missing, malformed, or unreadable file reads as `default`, and a malformed file is never overwritten. + +| Level | Collapsed quiet-tool cards | +|-------|----------------------------| +| `default` | Result previews, counts, and summaries, as described above. Bash rows keep pi's native drawing. | +| `minimal` | Only the command row for `read`, `write`, `bash`, `grep`, `find`, `ls`, and `edit`; the expand key still reveals the full result. Failed calls keep their bounded error tail. Bash rows draw as a Gentle card when the host supports `pi.registerToolRenderer`, with the bare command as the title. | + +Quiet tool cards redraw immediately after a change; bash rows follow on new calls. Gentle AI cards keep their full card and elapsed time at both levels. + ### Compact Code card With quiet tools enabled, `codemode` uses the same rounded **Code** card. The collapsed view shows up to eight observed child calls in their original order, including repeats, with Pi's actual status and available nonnegative duration. Additional calls and failures are counted. Error payloads have a separate two-row preview even when their child falls outside the first eight; final output has a three-row physical budget, and a full-output locator remains visible when available. diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 9b82ba1d4..ece5f060b 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -14,6 +14,7 @@ import { WorktreeChangesView } from "../lib/shell-changes-view.ts"; import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts"; import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts"; import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts"; +import { CARD_CONTENT, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts"; import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/command-palette.ts"; import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts"; import { discoverYoloUiAdapter, YOLO_DISPLAY, type YoloDisplay, type YoloUiAdapter } from "../lib/yolo-session-policy.ts"; @@ -1739,6 +1740,10 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p // preference fills it at startup and again on every session start. const applyCardStyle = () => setCardStyle(resolveCardStyle(animationOptions).style); applyCardStyle(); + // The Card content preference rides the same kind of slot: the quiet tool + // renderers read it live, so the saved level applies from the first render. + const applyCardContent = () => setCardContent(resolveCardContent(animationOptions).content); + applyCardContent(); const reportVim = (ctx: ExtensionContext, result: ReturnType) => { const source = result.source === "default" ? "built-in default" : `global file ${result.globalFile}`; const effective = prompt?.effectiveVimPolicy; @@ -1835,6 +1840,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p registry = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.cwd, deps.resolveWorktree); registry.start(); applyCardStyle(); + applyCardContent(); if (!ctx.hasUI) return; visualSettings = resolveVisualSettings(animationOptions).settings; if (!overrides.activeProfile) { @@ -2233,6 +2239,26 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p }, () => ({ title: `Cards · ${style}`, sample: `${cardStylePreview[style]}${resolveCardStyle(home).malformed ? " · malformed or unreadable file" : ""}` }), ); + // The content level picks between the quiet tools' result previews + // (default) and command-only cards (minimal). + const cardContentPreview = { + [CARD_CONTENT.DEFAULT]: "≡ read a.ts · result previews + counts · bash rows keep pi's native card", + [CARD_CONTENT.MINIMAL]: "≡ read a.ts · command only · ctrl+o expands · bash draws as a Gentle card", + }; + for (const content of Object.values(CARD_CONTENT)) add( + () => { + const current = resolveCardContent(home); + return `Card content: ${content}${current.content === content && !current.malformed ? " (current)" : ""}`; + }, + `Card content: ${content}. Quiet tool cards redraw now; bash rows follow on new calls.`, + () => { + writeCardContent(content, home); + setCardContent(content); + renderHost?.requestRender(); + requestCustomizeRender?.(); + }, + () => ({ title: `Cards · content ${content}`, sample: `${cardContentPreview[content]}${resolveCardContent(home).malformed ? " · malformed or unreadable file" : ""}` }), + ); category = "Sections"; for (const key of VISUAL_SECTION_KEYS) add( () => `Section ${key}: ${visual().visibility[key] ? "shown" : "hidden"}`, diff --git a/extensions/quiet-tools.ts b/extensions/quiet-tools.ts index b4ea8327a..96778db41 100644 --- a/extensions/quiet-tools.ts +++ b/extensions/quiet-tools.ts @@ -12,6 +12,7 @@ import { Text, type Component } from "@earendil-works/pi-tui"; import { homedir } from "node:os"; import { isAbsolute } from "node:path"; import { resolveGentleAiDevBinaryOverride, type GentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts"; +import { CARD_CONTENT, cardContent } from "../lib/card-content-policy.ts"; import { GentleAiElapsedTimingLedger } from "../lib/gentle-ai-elapsed-store.ts"; import { quietToolsEnabled } from "../lib/quiet-tools-config.ts"; import { registerCompactCodemode } from "../lib/codemode-renderer.ts"; @@ -56,6 +57,11 @@ const COLLAPSED_COUNT_LABELS: Partial> = { ls: "entries", }; +// Cards that show only the command while the Card content preference is +// minimal: their results stay one expand key away and never add rows to the +// transcript view. +const COMMAND_ONLY_TOOLS: ReadonlySet = new Set(["read", "bash", "grep", "find", "ls", "edit", "write"]); + const COLLAPSED_TAIL_LINE_LIMIT = 10; const PREVIEW_LINE_LIMIT = 3; @@ -486,7 +492,9 @@ function formatToolCall(toolName: QuietToolName, args: Record, case "bash": { const command = safeText(asString(args.command, "...")); const timeout = typeof args.timeout === "number" ? theme.fg("muted", ` (timeout ${args.timeout}s)`) : ""; - return `${theme.fg("toolTitle", theme.bold(`bash $ ${command}`))}${timeout}`; + // Minimal cards title bash with the bare command; the card glyph supplies the `$` prompt. + const title = cardContent() === CARD_CONTENT.MINIMAL ? command : `bash $ ${command}`; + return `${theme.fg("toolTitle", theme.bold(title))}${timeout}`; } case "grep": { let text = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${safeText(asString(args.pattern))}/`)} in ${safeText(shortenPath(args.path) || ".")}`; @@ -708,7 +716,7 @@ function gentleAiRenderTransition( return { directResult: false }; } -/** Rendering-only factory; Bash renderers are not attached to production native Bash. */ +/** Rendering-only factory; the default export attaches the Bash renderers to native Bash through pi.registerToolRenderer, so execution and schema stay pi's. */ export function createQuietToolRenderer( toolName: QuietToolName, resolveOverride: GentleAiDevBinaryOverrideResolver = () => undefined, @@ -758,6 +766,13 @@ export function createQuietToolRenderer( } const resultTone = toolTone(options.isPartial === true, isError); const carded = (component: () => Component, cacheable = options.isPartial !== true): Component => new ToolCardBody(component, resultTone, theme, cacheable); + // Under the minimal Card content preference, every quiet tool draws + // the command alone while collapsed; the expand key still reveals + // the full result. Failures keep their bounded error tail so a red + // card always says why. The default preference keeps the previews. + if (COMMAND_ONLY_TOOLS.has(toolName) && !options.expanded && !isError && cardContent() === CARD_CONTENT.MINIMAL) { + return carded(() => new Text("", 0, 0)); + } if (options.isPartial) { if (options.expanded) return carded(() => new Text(`${theme.fg("warning", partialLabel(toolName, text))}\n${theme.fg("muted", text)}`, 0, 0)); const visible = lastOutputLines(text, PREVIEW_LINE_LIMIT); @@ -848,5 +863,15 @@ export default function quietTools( for (const toolName of Object.keys(TOOL_CREATORS) as RegisteredToolName[]) { registerQuietTool(pi, toolName, resolveOverride, () => elapsedTiming); } + // Native Bash keeps pi's execution and schema; only its drawing becomes a + // Gentle card while the Card content preference is minimal, so quiet bash + // rows match the other tools. The resolver API is newer than this + // package's pinned pi types, so it is probed structurally; hosts without it + // — and the default content preference — keep pi's native bash rendering. + const host = pi as typeof pi & { registerToolRenderer?: (resolver: (toolName: string, next: () => unknown) => unknown) => void }; + if (typeof host.registerToolRenderer === "function") { + host.registerToolRenderer((toolName, next) => + toolName === "bash" && cardContent() === CARD_CONTENT.MINIMAL ? createQuietToolRenderer("bash", resolveOverride, () => elapsedTiming) : next()); + } return registerCompactCodemode(pi); } diff --git a/lib/card-content-policy.ts b/lib/card-content-policy.ts new file mode 100644 index 000000000..ab6c912b9 --- /dev/null +++ b/lib/card-content-policy.ts @@ -0,0 +1,85 @@ +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { gentlePiConfigHome } from "./agent-home.ts"; + +// The conversation card content level chosen in Gentle → Customize. `default` +// keeps the quiet tools' result previews; `minimal` draws every quiet tool +// card as the command alone. A missing file means default, so nobody loses the +// previews without choosing to; a malformed or unreadable one also reads as +// default, and the writer refuses +// to replace it so a hand edit is never lost. There is no environment override. +export const CARD_CONTENT_SCHEMA = "gentle-pi.card-content/v1"; +const CARD_CONTENT_FILE = "card-content.json"; + +export const CARD_CONTENT = { + DEFAULT: "default", + MINIMAL: "minimal", +} as const; + +export type CardContent = (typeof CARD_CONTENT)[keyof typeof CARD_CONTENT]; + +interface CardContentOptions { gentlePiConfigHome?: string } + +export interface CardContentResolution { + content: CardContent; + source: "global_file" | "default"; + malformed: boolean; + globalFile: string; +} + +function isCardContent(value: unknown): value is CardContent { + return value === CARD_CONTENT.DEFAULT || value === CARD_CONTENT.MINIMAL; +} + +export function parseCardContentFile(raw: string): CardContent | undefined { + try { + const value: unknown = JSON.parse(raw); + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + if (Object.keys(value).length !== 2 || !("schema" in value) || value.schema !== CARD_CONTENT_SCHEMA || !("content" in value)) return undefined; + return isCardContent(value.content) ? value.content : undefined; + } catch { return undefined; } +} + +export function resolveCardContent(options: CardContentOptions = {}): CardContentResolution { + const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), CARD_CONTENT_FILE); + try { + const content = parseCardContentFile(readFileSync(globalFile, "utf8")); + return { content: content ?? CARD_CONTENT.DEFAULT, source: "global_file", malformed: content === undefined, globalFile }; + } catch (error) { + const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; + return { content: CARD_CONTENT.DEFAULT, source: missing ? "default" : "global_file", malformed: !missing, globalFile }; + } +} + +export function writeCardContent(content: CardContent, options: CardContentOptions = {}): string { + if (!isCardContent(content)) throw new TypeError("Invalid card content"); + const home = options.gentlePiConfigHome ?? gentlePiConfigHome(); + const current = resolveCardContent({ gentlePiConfigHome: home }); + if (current.malformed) throw new Error(`Cannot update malformed or unreadable card content preference: ${current.globalFile}`); + const path = current.globalFile; + const temporary = `${path}.${randomUUID()}.tmp`; + mkdirSync(home, { recursive: true }); + try { + writeFileSync(temporary, `${JSON.stringify({ schema: CARD_CONTENT_SCHEMA, content })}\n`, { flag: "wx", mode: 0o600 }); + renameSync(temporary, path); + } finally { + try { unlinkSync(temporary); } catch { /* Rename consumed the temporary file. */ } + } + return path; +} + +// Pi loads every extension with its own module cache, so the renderers in +// quiet-tools and the customize command in gentle-shell never share a module +// instance. The process-wide slot keeps one live content level per process, +// exactly like the card style slot; an unset slot reads as default. +const CARD_CONTENT_SLOT = Symbol.for("gentle-pi.card-content"); +const contentState = globalThis as typeof globalThis & { [CARD_CONTENT_SLOT]?: unknown }; + +export function cardContent(): CardContent { + return contentState[CARD_CONTENT_SLOT] === CARD_CONTENT.MINIMAL ? CARD_CONTENT.MINIMAL : CARD_CONTENT.DEFAULT; +} + +export function setCardContent(content: CardContent): void { + contentState[CARD_CONTENT_SLOT] = content; +} diff --git a/tests/card-content-policy.test.ts b/tests/card-content-policy.test.ts new file mode 100644 index 000000000..a0af12c57 --- /dev/null +++ b/tests/card-content-policy.test.ts @@ -0,0 +1,68 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CARD_CONTENT, CARD_CONTENT_SCHEMA, cardContent, parseCardContentFile, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts"; + +const home = () => mkdtempSync(join(tmpdir(), "gentle-card-content-")); + +test("a missing preference means the default content level", () => { + const dir = home(); + assert.deepEqual(resolveCardContent({ gentlePiConfigHome: dir }), { content: "default", source: "default", malformed: false, globalFile: join(dir, "card-content.json") }); +}); + +test("the preference round-trips through the atomic writer", () => { + const dir = home(); + const path = writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: dir }); + assert.equal(path, join(dir, "card-content.json")); + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { schema: CARD_CONTENT_SCHEMA, content: "minimal" }); + assert.deepEqual(resolveCardContent({ gentlePiConfigHome: dir }), { content: "minimal", source: "global_file", malformed: false, globalFile: path }); + writeCardContent(CARD_CONTENT.DEFAULT, { gentlePiConfigHome: dir }); + assert.equal(resolveCardContent({ gentlePiConfigHome: dir }).content, "default"); + assert.deepEqual(readdirSync(dir), ["card-content.json"], "no temporary file survives the rename"); +}); + +test("the writer rejects values outside the content domain", () => { + const dir = home(); + assert.throws(() => writeCardContent("clean" as never, { gentlePiConfigHome: dir }), TypeError); + assert.equal(existsSync(join(dir, "card-content.json")), false); +}); + +test("invalid or unreadable preference files read as default and are never overwritten", () => { + for (const raw of ["", "{", "[]", "null", `{"schema":"${CARD_CONTENT_SCHEMA}","content":"clean"}`, `{"schema":"other/v1","content":"minimal"}`, `{"schema":"${CARD_CONTENT_SCHEMA}","content":"default","extra":1}`]) { + assert.equal(parseCardContentFile(raw), undefined, raw); + const dir = home(); + const path = join(dir, "card-content.json"); + writeFileSync(path, raw); + assert.deepEqual(resolveCardContent({ gentlePiConfigHome: dir }), { content: "default", source: "global_file", malformed: true, globalFile: path }, raw); + assert.throws(() => writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: dir }), /Cannot update malformed or unreadable card content preference/, raw); + assert.equal(readFileSync(path, "utf8"), raw, "the malformed file is preserved"); + } + const dir = home(); + mkdirSync(join(dir, "card-content.json")); + assert.equal(resolveCardContent({ gentlePiConfigHome: dir }).malformed, true, "a directory in its place is unreadable, not missing"); + if (process.getuid?.() !== 0) { + const locked = home(); + writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: locked }); + chmodSync(join(locked, "card-content.json"), 0o000); + try { + assert.deepEqual({ ...resolveCardContent({ gentlePiConfigHome: locked }), globalFile: "" }, { content: "default", source: "global_file", malformed: true, globalFile: "" }); + } finally { + chmodSync(join(locked, "card-content.json"), 0o600); + } + } +}); + +test("the live slot shares one content level per process and an unset slot reads default", (t) => { + const slot = Symbol.for("gentle-pi.card-content"); + const state = globalThis as typeof globalThis & { [slot]?: unknown }; + const found = state[slot]; + t.after(() => { state[slot] = found; }); + delete state[slot]; + assert.equal(cardContent(), CARD_CONTENT.DEFAULT); + setCardContent(CARD_CONTENT.DEFAULT); + assert.equal(cardContent(), CARD_CONTENT.DEFAULT); + setCardContent(CARD_CONTENT.MINIMAL); + assert.equal(cardContent(), CARD_CONTENT.MINIMAL); +}); diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 3696e2e08..b24cde29c 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -30,6 +30,7 @@ import { listVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts import { oddPhaseRegistry } from "../lib/odd-phase.ts"; import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts"; import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts"; +import { CARD_CONTENT, cardContent, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts"; import { claimNotificationOwner } from "../lib/notification-service.ts"; import { DEFAULT_NOTIFICATION_SETTINGS } from "../lib/notification-policy.ts"; @@ -3019,7 +3020,7 @@ test("customize Cards rows persist the card style and switch live conversation c const pending = commands.get("gentle:customize")!.handler("", ctx); await overlayReady; assert.ok(findCustomizeRow(ui, "Card style: float (current)")); - assert.match(ui.overlayView!.render(90).join("\n"), /Cards · 2\/2/); + assert.match(ui.overlayView!.render(90).join("\n"), /Cards · 2\/4/); assert.ok(findCustomizeRow(ui, "Card style: neon")); assert.ok(!ui.overlayView!.render(90).some((line) => line.includes("▸ Card style: neon (current)")), "neon is not current without a saved preference"); assert.equal(existsSync(join(home, "card-style.json")), false, "highlighting never applies"); @@ -3043,6 +3044,32 @@ test("customize Cards rows persist the card style and switch live conversation c ui.overlayView!.handleInput("\x1b"); await pending; }); +test("customize Cards rows persist the card content level and switch the live quiet tools", async (t) => { + const home = scopedDoubleEscCancelConfigHome(t); + const previous = cardContent(); + t.after(() => setCardContent(previous)); + const { pi, commands } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }); + assert.equal(cardContent(), CARD_CONTENT.DEFAULT, "no preference file means default"); + const { ctx, ui, overlayReady } = fakeContext(); + const pending = commands.get("gentle:customize")!.handler("", ctx); + await overlayReady; + assert.ok(findCustomizeRow(ui, "Card content: default (current)")); + assert.ok(findCustomizeRow(ui, "Card content: minimal")); + assert.equal(existsSync(join(home, "card-content.json")), false, "highlighting never applies"); + await customizeAction(ui, "Card content: minimal"); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(resolveCardContent({ gentlePiConfigHome: home }).content, "minimal"); + assert.equal(cardContent(), CARD_CONTENT.MINIMAL, "the live slot follows the choice"); + assert.match(ui.notices.at(-1)!, /Card content: minimal/); + assert.ok(findCustomizeRow(ui, "Card content: minimal (current)")); + assert.ok(findCustomizeRow(ui, "Card content: default")); + await customizeAction(ui, "Card content: default"); + assert.equal(resolveCardContent({ gentlePiConfigHome: home }).content, "default"); + assert.equal(cardContent(), CARD_CONTENT.DEFAULT); + ui.overlayView!.handleInput("\x1b"); await pending; +}); + test("T3 live style Cards action refreshes cached TODO, header, footer and prompt in both directions", async (t) => { const home = scopedDoubleEscCancelConfigHome(t); const previous = cardStyle(); @@ -3165,6 +3192,20 @@ test("the saved card style applies at startup and on every session start", async assert.equal(cardStyle(), CARD_STYLE.FLOAT); }); +test("the saved card content applies at startup and on every session start", async (t) => { + const home = scopedDoubleEscCancelConfigHome(t); + const found = cardContent(); + t.after(() => setCardContent(found)); + writeCardContent("minimal", { gentlePiConfigHome: home }); + const { pi, handlers } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home }); + assert.equal(cardContent(), CARD_CONTENT.MINIMAL); + writeCardContent("default", { gentlePiConfigHome: home }); + const { ctx } = fakeContext({ hasUI: false }); + for (const handler of handlers.get("session_start") ?? []) await handler({}, ctx); + assert.equal(cardContent(), CARD_CONTENT.DEFAULT); +}); + test("customize Cards rows refuse to overwrite a malformed preference", async (t) => { const home = scopedDoubleEscCancelConfigHome(t); const found = cardStyle(); diff --git a/tests/quiet-tool-rendering.test.ts b/tests/quiet-tool-rendering.test.ts index 7b4d0ec3d..080ad0407 100644 --- a/tests/quiet-tool-rendering.test.ts +++ b/tests/quiet-tool-rendering.test.ts @@ -8,6 +8,7 @@ import { imageFallback, visibleWidth } from "@earendil-works/pi-tui"; import { cardBody, cardHint, cardTitle, cardTone } from "./gentle-card-text.ts"; import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts"; import { stripAnsi } from "../lib/terminal-theme.ts"; +import { CARD_CONTENT, cardContent, setCardContent } from "../lib/card-content-policy.ts"; import piPretty from "../extensions/pi-pretty.ts"; import productionQuietTools, { createQuietToolRenderer, @@ -1645,3 +1646,99 @@ test("quiet cards stay outlined in the neon style with a background-capable them assert.match(rows.at(-1)!, /^╰─+╯$/); assert.doesNotMatch(rows.join("\n"), /▎/); }); + +// The Card content slot is process-wide; each test that needs a level sets it +// and restores the previous one. +function useCardContent(t: { after(fn: () => void): void }, level: (typeof CARD_CONTENT)[keyof typeof CARD_CONTENT]): void { + const previous = cardContent(); + t.after(() => setCardContent(previous)); + setCardContent(level); +} + +test("the Card content preference defaults to the result previews", () => { + assert.equal(cardContent(), CARD_CONTENT.DEFAULT); + const rendered = renderToolResult(registeredQuietTools().get("read"), textResult("first line\nsecond line"), { expanded: false, isPartial: false }, {}); + assert.match(rendered, /first line/); +}); + +test("minimal command-only cards keep the bounded error tail of failed calls", (t) => { + useCardContent(t, CARD_CONTENT.MINIMAL); + const tools = registeredQuietTools(); + for (const toolName of ["read", "bash", "write", "grep", "find", "ls", "edit"] as const) { + const rendered = renderToolResult(tools.get(toolName), textResult("error one\nerror two\nerror three\nerror four"), { expanded: false, isPartial: false, isError: true }, toolName === "bash" ? { args: { command: "false" } } : {}); + assert.match(rendered, /error two\nerror three\nerror four/, `${toolName}: a failed call says why while collapsed`); + assert.doesNotMatch(rendered, /to expand/); + } +}); + +test("every quiet tool card shows only the command while collapsed and stays expandable", (t) => { + useCardContent(t, CARD_CONTENT.MINIMAL); + const tools = registeredQuietTools(); + const finished = { isPartial: false, executionStarted: true }; + const cases: Array<[string, Record, string, string]> = [ + ["read", { path: "a.ts" }, "first line\nsecond line", "first line"], + ["write", { path: "b.ts", content: "body" }, "Successfully wrote 4 bytes", "body"], + ["bash", { command: "printf hi" }, "hi\nthere", "there"], + ["grep", { pattern: "needle", path: "src" }, "src/a.ts:1:needle", "src/a.ts:1:needle"], + ["find", { pattern: "*.ts", path: "src" }, "src/a.ts\nsrc/b.ts", "src/b.ts"], + ["ls", { path: "src" }, "file-a.ts\nfile-b.ts", "file-b.ts"], + ["edit", { path: "file.ts", edits: [] }, "Applied edits", "Applied edits"], + ]; + for (const [name, args, output, visible] of cases) { + const tool = tools.get(name); + const context = routineRenderContext({ args, ...finished }); + const callRows = tool.renderCall(args, passthroughTheme, context).render(120).map(stripAnsi); + assert.ok(callRows.some((row) => row.includes("to expand")), `${name}: the expand key stays`); + const collapsed = renderToString(tool.renderResult(textResult(output), { expanded: false, isPartial: false }, passthroughTheme, context)); + assert.equal(collapsed.trim(), "", `${name}: no result rows while collapsed`); + const expanded = renderToString(tool.renderResult(textResult(output), { expanded: true, isPartial: false }, passthroughTheme, context)); + assert.match(expanded, new RegExp(visible.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `${name}: expanding reveals the result`); + } +}); + +test("the default Card content preference restores result previews and native bash drawing", () => { + const tools = registeredQuietTools(); + const read = tools.get("read"); + setCardContent(CARD_CONTENT.DEFAULT); + try { + const context = routineRenderContext({ args: { path: "a.ts" }, executionStarted: true, isPartial: false }); + const collapsed = renderToString(read.renderResult(textResult("first line\nsecond line"), { expanded: false, isPartial: false }, passthroughTheme, context)); + assert.match(collapsed, /first line\nsecond line/, "default content keeps the read preview"); + const write = renderToString(tools.get("write").renderResult(textResult("Successfully wrote 4 bytes"), { expanded: false, isPartial: false }, passthroughTheme, routineRenderContext({ args: { path: "b.ts", content: "body" }, executionStarted: true, isPartial: false }))); + assert.match(write, /wrote 4 bytes/, "default content keeps the write summary"); + + const { pi } = createPi(); + const resolvers: Array<(toolName: string, next: () => unknown) => unknown> = []; + (pi as any).registerToolRenderer = (resolver: (toolName: string, next: () => unknown) => unknown) => { + resolvers.push(resolver); + }; + withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any)); + const native = { marker: "native" }; + assert.equal(resolvers.at(-1)!("bash", () => native), native, "default content defers bash drawing to the native renderer"); + assert.equal(resolvers.at(-1)!("read", () => native), native, "every other tool still defers"); + } finally { + setCardContent(CARD_CONTENT.DEFAULT); + } +}); + +test("the production factory takes over native bash drawing through a tool renderer resolver", (t) => { + useCardContent(t, CARD_CONTENT.MINIMAL); + const { pi } = createPi(); + const resolvers: Array<(toolName: string, next: () => unknown) => unknown> = []; + (pi as any).registerToolRenderer = (resolver: (toolName: string, next: () => unknown) => unknown) => { + resolvers.push(resolver); + }; + withEnv({ GENTLE_PI_QUIET_TOOLS: undefined }, () => quietTools(pi as any)); + assert.ok(resolvers.length >= 1, "quiet tools registers a tool renderer resolver"); + const resolver = resolvers.at(-1)!; + const native = { marker: "native" }; + assert.equal(resolver("read", () => native), native, "every other tool keeps the next renderer"); + const bash = resolver("bash", () => native) as any; + assert.notEqual(bash, native); + assert.equal(bash.renderShell, "self"); + assert.equal(typeof bash.renderCall, "function"); + assert.equal(typeof bash.renderResult, "function"); + const call = bash.renderCall({ command: "printf hi", timeout: 5 }, passthroughTheme, routineRenderContext({ args: { command: "printf hi", timeout: 5 }, executionStarted: true, isPartial: false })); + assert.match(renderToString(call), /printf hi \(timeout 5s\)/); +}); +