diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index af85115f0..54979ca40 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -1,4 +1,4 @@ -import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; +import { Box, Container, Markdown, type MarkdownTheme, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui"; import type { BranchSummaryMessage } from "../../../core/messages.ts"; import { getMarkdownTheme, theme } from "../theme/theme.ts"; import { keyText } from "./keybinding-hints.ts"; @@ -31,20 +31,21 @@ export class BranchSummaryMessageComponent extends Box { private updateDisplay(): void { this.clear(); + const content = new Container(); const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`); - this.addChild(new Text(label, 0, 0)); - this.addChild(new Spacer(1)); + content.addChild(new Text(label, 0, 0)); + content.addChild(new Spacer(1)); if (this.expanded) { const header = "**Branch Summary**\n\n"; - this.addChild( + content.addChild( new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, { color: (text: string) => theme.fg("customMessageText", text), }), ); } else { - this.addChild( + content.addChild( new Text( theme.fg("customMessageText", "Branch summary (") + theme.fg("dim", keyText("app.tools.expand")) + @@ -54,5 +55,13 @@ export class BranchSummaryMessageComponent extends Box { ), ); } + + this.addChild( + new MouseRegion(content, (event) => { + if (event.type !== "click" || event.button !== "left") return undefined; + this.setExpanded(!this.expanded); + return { handled: true }; + }), + ); } } diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index cad613d76..dc5b9fbdb 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -1,4 +1,4 @@ -import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; +import { Box, Container, Markdown, type MarkdownTheme, MouseRegion, Spacer, Text } from "@earendil-works/pi-tui"; import type { CompactionSummaryMessage } from "../../../core/messages.ts"; import { getMarkdownTheme, theme } from "../theme/theme.ts"; import { keyText } from "./keybinding-hints.ts"; @@ -31,21 +31,22 @@ export class CompactionSummaryMessageComponent extends Box { private updateDisplay(): void { this.clear(); + const content = new Container(); const tokenStr = this.message.tokensBefore.toLocaleString(); const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`); - this.addChild(new Text(label, 0, 0)); - this.addChild(new Spacer(1)); + content.addChild(new Text(label, 0, 0)); + content.addChild(new Spacer(1)); if (this.expanded) { const header = `**Compacted from ${tokenStr} tokens**\n\n`; - this.addChild( + content.addChild( new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, { color: (text: string) => theme.fg("customMessageText", text), }), ); } else { - this.addChild( + content.addChild( new Text( theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) + theme.fg("dim", keyText("app.tools.expand")) + @@ -55,5 +56,13 @@ export class CompactionSummaryMessageComponent extends Box { ), ); } + + this.addChild( + new MouseRegion(content, (event) => { + if (event.type !== "click" || event.button !== "left") return undefined; + this.setExpanded(!this.expanded); + return { handled: true }; + }), + ); } } diff --git a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts index e09febc7c..789a4dff1 100644 --- a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -1,4 +1,4 @@ -import { Box, Markdown, type MarkdownTheme, Text } from "@earendil-works/pi-tui"; +import { Box, Container, Markdown, type MarkdownTheme, MouseRegion, Text } from "@earendil-works/pi-tui"; import type { ParsedSkillBlock } from "../../../core/agent-session.ts"; import { getMarkdownTheme, theme } from "../theme/theme.ts"; import { keyText } from "./keybinding-hints.ts"; @@ -32,13 +32,14 @@ export class SkillInvocationMessageComponent extends Box { private updateDisplay(): void { this.clear(); + const content = new Container(); if (this.expanded) { // Expanded: label + skill name header + full content const label = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m`); - this.addChild(new Text(label, 0, 0)); + content.addChild(new Text(label, 0, 0)); const header = `**${this.skillBlock.name}**\n\n`; - this.addChild( + content.addChild( new Markdown(header + this.skillBlock.content, 0, 0, this.markdownTheme, { color: (text: string) => theme.fg("customMessageText", text), }), @@ -49,7 +50,15 @@ export class SkillInvocationMessageComponent extends Box { theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + theme.fg("customMessageText", this.skillBlock.name) + theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`); - this.addChild(new Text(line, 0, 0)); + content.addChild(new Text(line, 0, 0)); } + + this.addChild( + new MouseRegion(content, (event) => { + if (event.type !== "click" || event.button !== "left") return undefined; + this.setExpanded(!this.expanded); + return { handled: true }; + }), + ); } } diff --git a/packages/coding-agent/test/collapsible-message-components.test.ts b/packages/coding-agent/test/collapsible-message-components.test.ts new file mode 100644 index 000000000..4ad651b70 --- /dev/null +++ b/packages/coding-agent/test/collapsible-message-components.test.ts @@ -0,0 +1,83 @@ +import type { Box, TuiMouseEvent } from "@earendil-works/pi-tui"; +import { beforeAll, describe, expect, test } from "vitest"; +import { BranchSummaryMessageComponent } from "../src/modes/interactive/components/branch-summary-message.ts"; +import { CompactionSummaryMessageComponent } from "../src/modes/interactive/components/compaction-summary-message.ts"; +import { SkillInvocationMessageComponent } from "../src/modes/interactive/components/skill-invocation-message.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +const WIDTH = 80; + +function renderText(component: Box): string { + return stripAnsi(component.render(WIDTH).join("\n")); +} + +function clickRow(component: Box, marker: string): void { + const lines = component.render(WIDTH); + const row = lines.findIndex((line) => stripAnsi(line).includes(marker)); + expect(row).toBeGreaterThanOrEqual(0); + const event: TuiMouseEvent = { + type: "click", + button: "left", + x: 2, + y: row, + screenX: 2, + screenY: row, + width: WIDTH, + height: lines.length, + shift: false, + alt: false, + ctrl: false, + clickCount: 1, + }; + expect(component.handleMouse(event)?.handled).toBe(true); +} + +describe("collapsible message components", () => { + beforeAll(() => initTheme("dark")); + + test("toggles a compaction summary when clicked", () => { + const component = new CompactionSummaryMessageComponent({ + role: "compactionSummary", + summary: "compaction details", + tokensBefore: 1234, + timestamp: Date.now(), + }); + + expect(renderText(component)).not.toContain("compaction details"); + clickRow(component, "[compaction]"); + expect(renderText(component)).toContain("compaction details"); + clickRow(component, "[compaction]"); + expect(renderText(component)).not.toContain("compaction details"); + }); + + test("toggles a branch summary when clicked", () => { + const component = new BranchSummaryMessageComponent({ + role: "branchSummary", + summary: "branch details", + fromId: "entry-1", + timestamp: Date.now(), + }); + + expect(renderText(component)).not.toContain("branch details"); + clickRow(component, "[branch]"); + expect(renderText(component)).toContain("branch details"); + clickRow(component, "[branch]"); + expect(renderText(component)).not.toContain("branch details"); + }); + + test("toggles a skill invocation when clicked", () => { + const component = new SkillInvocationMessageComponent({ + name: "example-skill", + location: "/tmp/example-skill.md", + content: "skill details", + userMessage: undefined, + }); + + expect(renderText(component)).not.toContain("skill details"); + clickRow(component, "[skill]"); + expect(renderText(component)).toContain("skill details"); + clickRow(component, "[skill]"); + expect(renderText(component)).not.toContain("skill details"); + }); +});