diff --git a/README.md b/README.md index 3e85c6e..ace4e55 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,48 @@ app.render(({ ui, theme }) => { await app.start(); ``` +## Copy summaries as Markdown + +Enable copy icons for summary panes throughout an app: + +```ts +const app = await createApp({ + copyMarkdown: true, + markdownContext: () => `Host: ${hostname}\nReporting period: ${period}`, +}); + +app.render(({ ui }) => { + ui.panel({ title: "Status" }, (p) => { + p.keyValues([{ label: "Connection", value: "Ready" }]); + }); + ui.copyButton({ markdown: () => "## Status\n\nReady\n", width: 6 }); +}); +``` + +Click **⧉ MD**, or Tab / Shift+Tab to focus a control and Enter / Space to copy. +Other navigation keys return focus to the app. ASCII terminals display `C MD`; +narrow panes show only the icon. A short notice confirms the clipboard request. + +Automatic exports include the pane title, subtitle, context, text, labeled values, +meters, progress, and graph summaries (latest/min/max/sample count). Values are +captured before wrapping and clipping. Tables, lists, logs, trees, input fields, +and raw drawing callbacks are excluded. Panels containing only data rows have no +copy icon. Layout branches that the app does not build cannot be exported. + +Set `copyMarkdown: true` on one panel or modal to enable it individually, +`copyMarkdown: false` to exclude it (including from parent exports), or provide +a Markdown string/callback for a custom summary. `ui.copyButton()` places the +same control in a status strip or custom layout. Custom Markdown is copied as +provided; `markdownText(value)` escapes plain values for interpolation. + +The default clipboard writer uses OSC 52 through the terminal, including over +SSH, and wraps the sequence for tmux. Terminal clipboard support must be enabled; +“Markdown copy sent” confirms delivery of the request, since terminals do not +acknowledge clipboard writes. Oversized exports fail explicitly instead of being +truncated. Supply `clipboard: (text) => ...` on `createApp()` to use another writer. +No shell command is run. `renderToScreen()` records copies in `screen.copied` for +interaction tests without changing the clipboard. + ## See it running Ten screens. Real metrics on Linux, macOS and Windows, with no native dependencies. diff --git a/apps/demo/README.md b/apps/demo/README.md index cdbc37c..f9c322e 100644 --- a/apps/demo/README.md +++ b/apps/demo/README.md @@ -68,7 +68,8 @@ npx --yes @profullstack/hqtui-demo@latest # Node 22.6+ works too | Key | Action | |---|---| -| `1`–`0`, `w`, `Tab` | Switch screens | +| `1`–`0`, `w` | Switch screens | +| `Tab`, `Shift+Tab` | Focus controls; Enter / Space copies a focused summary | | `F1` | Help | | `F2` | Cycle theme | | `F3` | Filter processes | @@ -81,6 +82,10 @@ npx --yes @profullstack/hqtui-demo@latest # Node 22.6+ works too Mouse works too: click the tabs and buttons, scroll the process list. +Click **⧉ MD** in a summary pane to copy its facts as Markdown, with the host, +screen, metric source and update state. Process and journal rows are excluded. +The terminal must support OSC 52 clipboard writes (also usable over SSH). + The termination dialog sends **SIGTERM** by default, allowing the process to clean up. **Force kill (-9 / SIGKILL)** starts unchecked each time. Use Tab, Shift+Tab or the arrow keys to move between Yes, No and the checkbox; Space diff --git a/apps/demo/package.json b/apps/demo/package.json index 403fc9b..2860964 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui-demo", - "version": "0.6.2", + "version": "0.6.3", "description": "The HQTUI reference dashboard: a btop-grade terminal system monitor. Runs on real system metrics or a deterministic simulation.", "license": "MIT", "type": "module", @@ -27,7 +27,7 @@ "audit:scroll": "bun scripts/scrollaudit.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.6.2" + "@profullstack/hqtui": "^0.6.3" }, "publishConfig": { "access": "public" diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index cb88015..94a7e45 100755 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -52,7 +52,7 @@ function parseArgs(argv: string[]): Options { case "-h": case "--help": printHelp(); process.exit(0); case "-v": - case "--version": console.log("hqtui-demo 0.6.2"); process.exit(0); + case "--version": console.log("hqtui-demo 0.6.3"); process.exit(0); } } return options; @@ -76,7 +76,8 @@ Options: -v, --version Show the version Keys: - 1-0/w / Tab screens F2 theme F3 filter F6 sort Ctrl+K palette + 1-0/w screens F2 theme F3 filter F6 sort Ctrl+K palette + Tab focus / Enter copy summary ↑/↓ select Space pause F1 help q quit Enter terminate selected process (SIGTERM; optional Force -9 in dialog) `); @@ -109,6 +110,8 @@ async function main(): Promise { state.themeIndex = Math.max(0, themeList.findIndex((t) => t.name === options.theme || t === (themes as never)[options.theme])); const app = await createApp({ + copyMarkdown: true, + markdownContext: () => `HQTUI demo · ${state.sample.system.hostname} · ${state.screen}\nSource: ${state.source} · ${state.paused ? "paused" : "live"}`, theme: themeList[state.themeIndex] ?? themes.dark, fps: options.fps, title: "hqtui demo", @@ -318,7 +321,7 @@ async function main(): Promise { { key: "F6", label: `Sort: ${state.sort}`, onPress: () => press("f6") }, ...(state.screen === "dashboard" ? [{ key: "Enter", label: "Kill", onPress: () => openKillDialog(state) }] : []), { key: "^K", label: "Palette", onPress: () => press("ctrl+k") }, - { key: "Tab", label: "Screen", onPress: () => press("tab") }, + { key: "Tab", label: "Focus", onPress: () => app.focusNext() }, { key: "q", label: "Quit", onPress: () => press("q") }, ], right: [{ label: `${num(state.renderMs, 2)}ms ${state.changedCells} cells ${state.bytes}B` }], @@ -330,7 +333,8 @@ async function main(): Promise { width: 62, height: 20, message: - "1-0, w or Tab switch screens; w is the clickable world map.\n" + + "1-0 or w switch screens; w is the clickable world map.\n" + + "⧉ MD copies a pane summary. Tab focuses, Enter copies.\n" + "F2 cycles themes, F3 filters processes, F6 changes sort.\n" + "c collapses adjacent panel borders into shared lines.\n" + "Ctrl+K opens the command palette, Space pauses updates.\n" + @@ -347,8 +351,8 @@ async function main(): Promise { "and the full journal. It does not add temperatures.\n" + " sudo -E env \"PATH=$PATH\" bunx @profullstack/hqtui-demo") : "All metrics available on this platform.") + - "\n\nPress any key to close.", - buttons: [{ label: "Close", focused: true, onPress: () => { state.showHelp = false; } }], + "\n\nEsc or Close dismisses help.", + buttons: [{ label: "Close", onPress: () => { state.showHelp = false; } }], onDismiss: () => { state.showHelp = false; }, }); } diff --git a/apps/demo/src/screens/input.ts b/apps/demo/src/screens/input.ts index 35d6b78..5db3dca 100644 --- a/apps/demo/src/screens/input.ts +++ b/apps/demo/src/screens/input.ts @@ -15,6 +15,7 @@ export function inputScreen(ui: Container, state: DemoState, theme: Theme): void p.list({ items: state.keyLog.slice(-20).reverse() }); }); row.panel({ title: "Try it" }, (p) => { + p.text("⧉ MD copies this pane as Markdown for people and agents.", { wrap: true }); p.text("Press any key — modifiers are normalized.", { fg: theme.foreground, size: 1 }); p.label("Arrows, Function keys, Ctrl/Alt/Shift combinations,", { size: 1 }); p.label("paste, focus, mouse move, click, drag and scroll.", { size: 1 }); diff --git a/apps/demo/test/markdown-copy.test.ts b/apps/demo/test/markdown-copy.test.ts new file mode 100644 index 0000000..10673f5 --- /dev/null +++ b/apps/demo/test/markdown-copy.test.ts @@ -0,0 +1,26 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderToScreen } from "@profullstack/hqtui/testing"; +import { createSystemSimulation } from "../src/simulation.ts"; +import { createState } from "../src/state.ts"; +import { dashboardScreen } from "../src/screens/dashboard.ts"; + +test("demo summary panes export system facts without process or journal rows", () => { + const sample = createSystemSimulation({ seed: 1 }).current(); + sample.system.hostname = "test-host"; + sample.processes[0].command = "DO NOT EXPORT PROCESS ROW"; + const state = createState(sample, "simulated", []); + const screen = renderToScreen(({ ui, theme }) => dashboardScreen(ui, state, theme), { + width: 180, height: 52, copyMarkdown: true, + markdownContext: "HQTUI demo · test-host · dashboard\nSource: simulated · live", + }); + for (const r of screen.regions) screen.click(r.rect.x, r.rect.y); + const system = screen.copied.find((text) => text.startsWith("## System")); + assert.ok(system); + assert.ok(system.includes("**Hostname:** test-host")); + assert.ok(system.includes("**Source:** simulated")); + assert.ok(system.includes("HQTUI demo · test-host")); + assert.ok(screen.copied.some((text) => text.startsWith("## CPU Overview"))); + assert.ok(!screen.copied.join("\n").includes("DO NOT EXPORT PROCESS ROW")); + assert.ok(!screen.copied.some((text) => /^## (Processes|Journal)/.test(text))); +}); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 7333f73..44972fc 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -157,7 +157,7 @@ export default async function Home() { High Quality Terminal UI for TypeScript, Rust, Go, Python, Zig and C++

- v0.6.2 · {COUNT} language demos · MIT + v0.6.3 · {COUNT} language demos · MIT

Terminal dashboards that diff --git a/bun.lock b/bun.lock index 7205eed..b386a46 100644 --- a/bun.lock +++ b/bun.lock @@ -21,12 +21,12 @@ }, "apps/demo": { "name": "@profullstack/hqtui-demo", - "version": "0.6.2", + "version": "0.6.3", "bin": { - "hqtui-demo": "./src/main.ts", + "hqtui-demo": "./bin/hqtui-demo.mjs", }, "dependencies": { - "@profullstack/hqtui": "workspace:*", + "@profullstack/hqtui": "^0.6.3", }, }, "apps/web": { @@ -63,7 +63,7 @@ }, "packages/hqtui": { "name": "@profullstack/hqtui", - "version": "0.6.2", + "version": "0.6.3", "bin": { "hqtui": "./bin/hqtui.mjs", }, diff --git a/docs/markdown-copy-release.md b/docs/markdown-copy-release.md new file mode 100644 index 0000000..bf1d1f2 --- /dev/null +++ b/docs/markdown-copy-release.md @@ -0,0 +1,20 @@ +# Markdown summary copy — 0.6.3 + +Summary panels and modals can export Markdown through a header copy icon. +Apps opt in with `copyMarkdown`; `markdownContext` carries the host, source and +reporting period. `copyButton` supports custom status strips. Tables, logs, trees, +lists, inputs and raw drawing callbacks are excluded from automatic summaries. + +The TypeScript demo enables the feature. The corresponding Crawlproof and +CoinPay integrations require HQTUI 0.6.3 or later in the 0.6 series. + +Release the HQTUI library before its demo and consumer packages, then regenerate +consumer lockfiles against the published tarball. Build the library and demo with +`bun run build`. Local testing can install all prepared package tarballs together +with `npm install --prefix `. + +Validation covers semantic exports before clipping, nested panels and opt-outs, +ASCII and narrow headers, mouse and keyboard activation, modal dismissal, +clipboard failures, UTF-8 OSC 52 payloads and tmux wrapping. The installed demo +was also exercised in a PTY: Tab/Enter emitted a Markdown payload and q exited +cleanly. Terminal clipboard support must be enabled for OSC 52 delivery. diff --git a/packages/hqtui/README.md b/packages/hqtui/README.md index 40e2dd0..f557540 100644 --- a/packages/hqtui/README.md +++ b/packages/hqtui/README.md @@ -149,3 +149,45 @@ gracefully on limited terminals (no mouse, quantized color, ASCII instead of Bra ## License MIT. + +## Copy summaries as Markdown + +Enable copy icons for summary panes throughout an app: + +```ts +const app = await createApp({ + copyMarkdown: true, + markdownContext: () => `Host: ${hostname}\nReporting period: ${period}`, +}); + +app.render(({ ui }) => { + ui.panel({ title: "Status" }, (p) => { + p.keyValues([{ label: "Connection", value: "Ready" }]); + }); + ui.copyButton({ markdown: () => "## Status\n\nReady\n", width: 6 }); +}); +``` + +Click **⧉ MD**, or Tab / Shift+Tab to focus a control and Enter / Space to copy. +Other navigation keys return focus to the app. ASCII terminals display `C MD`; +narrow panes show only the icon. A short notice confirms the clipboard request. + +Automatic exports include the pane title, subtitle, context, text, labeled values, +meters, progress, and graph summaries (latest/min/max/sample count). Values are +captured before wrapping and clipping. Tables, lists, logs, trees, input fields, +and raw drawing callbacks are excluded. Panels containing only data rows have no +copy icon. Layout branches that the app does not build cannot be exported. + +Set `copyMarkdown: true` on one panel or modal to enable it individually, +`copyMarkdown: false` to exclude it (including from parent exports), or provide +a Markdown string/callback for a custom summary. `ui.copyButton()` places the +same control in a status strip or custom layout. Custom Markdown is copied as +provided; `markdownText(value)` escapes plain values for interpolation. + +The default clipboard writer uses OSC 52 through the terminal, including over +SSH, and wraps the sequence for tmux. Terminal clipboard support must be enabled; +“Markdown copy sent” confirms delivery of the request, since terminals do not +acknowledge clipboard writes. Oversized exports fail explicitly instead of being +truncated. Supply `clipboard: (text) => ...` on `createApp()` to use another writer. +No shell command is run. `renderToScreen()` records copies in `screen.copied` for +interaction tests without changing the clipboard. diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json index 18f86bd..135bdd4 100644 --- a/packages/hqtui/package.json +++ b/packages/hqtui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui", - "version": "0.6.2", + "version": "0.6.3", "description": "High Quality Terminal UI for TypeScript. btop-grade dashboards with a one-import API, dark by default, zero runtime dependencies.", "license": "MIT", "type": "module", diff --git a/packages/hqtui/src/app.ts b/packages/hqtui/src/app.ts index 696f348..ff7f878 100644 --- a/packages/hqtui/src/app.ts +++ b/packages/hqtui/src/app.ts @@ -8,8 +8,16 @@ import { Surface, createSurface } from "./surface.ts"; import { Container, countClicks, dispatchHit, type RenderContext, type HitRegion, type FocusRegistration, type OverlayOptions } from "./ui.ts"; import type { InputEvent, KeyEvent, MouseEvent, PasteEvent, FocusEvent } from "./input.ts"; import { matchKey } from "./input.ts"; +import { clipboardSequence } from "./markdown.ts"; +import { truncate, stringWidth } from "./unicode.ts"; export interface AppOptions extends TerminalOptions { + /** Add Markdown copy icons to summary panes. Data rows are excluded. */ + copyMarkdown?: boolean; + /** Fresh context attached to summaries each frame, e.g. host and period. */ + markdownContext?: string | (() => string); + /** Override OSC 52 clipboard delivery, e.g. for a browser terminal. */ + clipboard?: (text: string) => void | Promise; /** Theme object or built-in name. Defaults to the dark theme. */ theme?: Theme | ThemeName | string; /** Cap on frames per second. Default 30, or 15 over SSH. */ @@ -101,6 +109,10 @@ export class App { private focusIndex = 0; private focusCount = 0; private focusActions: (() => void)[] = []; + private focusConsumesKey: boolean[] = []; + private focusEngaged = false; + private copyNotice: { text: string; failed: boolean } | null = null; + private copyTimer: NodeJS.Timeout | null = null; private hits: HitRegion[] = []; private overlays: { draw: (root: Surface) => void; options?: OverlayOptions }[] = []; private modal: OverlayOptions | undefined; @@ -135,6 +147,25 @@ export class App { return this.lastStats; } + /** Copy a user-requested summary. OSC 52 requires terminal clipboard support. */ + async copyToClipboard(value: string | (() => string)): Promise { + try { + const text = typeof value === "function" ? value() : value; + if (this.options.clipboard) await this.options.clipboard(text); + else { + if (!this.capabilities.tty) throw new Error("Clipboard needs an interactive terminal."); + this.terminal.write(clipboardSequence(text, !!process.env.TMUX)); + } + this.copyNotice = { text: this.options.clipboard ? "Markdown copied" : "Markdown copy sent", failed: false }; + } catch (error) { + this.copyNotice = { text: `Copy failed: ${error instanceof Error ? error.message : String(error)}`, failed: true }; + } + if (this.copyTimer) clearTimeout(this.copyTimer); + this.copyTimer = setTimeout(() => { this.copyNotice = null; this.copyTimer = null; this.invalidate(); }, 3000); + this.copyTimer.unref?.(); + this.invalidate(); + } + /** Register the view. Called on every frame; keep it pure and cheap. */ render(fn: RenderFn): this { this.renderFn = fn; @@ -194,7 +225,10 @@ export class App { /** Move keyboard focus. Wraps around. */ focusNext(delta = 1): void { if (this.focusCount === 0) return; - this.focusIndex = (this.focusIndex + delta + this.focusCount) % this.focusCount; + this.focusIndex = this.focusConsumesKey.some(Boolean) && !this.focusEngaged && !this.modal + ? delta < 0 ? this.focusCount - 1 : 0 + : (this.focusIndex + delta + this.focusCount) % this.focusCount; + this.focusEngaged = true; this.dirty = true; } @@ -312,6 +346,9 @@ export class App { /** Stop the loop and restore the terminal. */ stop(): void { + if (this.copyTimer) clearTimeout(this.copyTimer); + this.copyTimer = null; + this.copyNotice = null; if (!this.running) return; this.running = false; if (this.timer) clearInterval(this.timer); @@ -338,15 +375,18 @@ export class App { return; } if (this.options.focusNavigation !== false) { + if (!this.modal && !["tab", "enter", "space"].includes(event.name)) this.focusEngaged = false; if (event.name === "tab") { this.focusNext(event.shift ? -1 : 1); - if (this.modal) return; + if (this.modal || this.focusConsumesKey.some(Boolean)) return; } else if (this.modal && ["left", "right", "up", "down"].includes(event.name)) { this.focusNext(event.name === "left" || event.name === "up" ? -1 : 1); return; } else if (event.name === "enter" || event.name === "space") { - const consumed = this.modal && this.focusActions[this.focusIndex]; - this.activateFocused(); + const copyFocused = this.focusConsumesKey[this.focusIndex]; + const active = !copyFocused || this.focusEngaged || this.modal; + const consumed = active && (this.modal || copyFocused) && this.focusActions[this.focusIndex]; + if (active) this.activateFocused(); // A callback may have closed the dialog. Its Enter must not then // reach the app's key handler and open it again (or pause a demo). if (consumed) return; @@ -392,6 +432,7 @@ export class App { } private dispatchMouse(event: MouseEvent): void { + if (event.action === "press" && !this.modal) this.focusEngaged = false; if (dispatchHit(this.hits, event)) this.dirty = true; } @@ -412,6 +453,7 @@ export class App { this.hits = []; this.overlays = []; this.focusActions = []; + this.focusConsumesKey = []; let focusCursor = 0; const wasModal = this.modal !== undefined; const modalFocusIndex = this.focusIndex; @@ -429,10 +471,14 @@ export class App { focusIndex: this.focusIndex, collapseBorders: this.options.collapseBorders ?? false, reducedMotion: this.options.reducedMotion ?? false, - registerFocus: (action?: () => void): FocusRegistration => { + copyMarkdown: this.options.copyMarkdown, + markdownContext: typeof this.options.markdownContext === "function" ? this.options.markdownContext() : this.options.markdownContext, + copyText: (text) => { void this.copyToClipboard(text); }, + registerFocus: (action?: () => void, consumeKey = false): FocusRegistration => { const index = focusCursor++; if (action) this.focusActions[index] = action; - return { index, focused: index === this.focusIndex }; + this.focusConsumesKey[index] = consumeKey; + return { index, focused: index === this.focusIndex && (!consumeKey || this.focusEngaged || !!this.modal) }; }, hit: (region) => this.hits.push(region), overlay: (draw, options) => this.overlays.push({ draw, options }), @@ -461,11 +507,20 @@ export class App { ctx.focusIndex = this.focusIndex; focusCursor = 0; this.focusActions = []; + this.focusConsumesKey = []; this.modal = overlay.options; } overlay.draw(root); } + if (this.copyNotice) { + const label = truncate(` ${this.copyNotice.text} `, root.width); + root.text(Math.max(0, root.width - stringWidth(label)), root.height - 1, label, { + fg: this.copyNotice.failed ? this.theme.danger : this.theme.success, + bg: this.theme.surface, + }); + } + this.focusCount = Math.max(focusCursor, 0); if (this.focusCount > 0 && this.focusIndex >= this.focusCount) { this.focusIndex = 0; diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts index e0a40c0..b5ed320 100644 --- a/packages/hqtui/src/cli.ts +++ b/packages/hqtui/src/cli.ts @@ -13,7 +13,7 @@ import { detectCapabilities } from "./capabilities.ts"; import { themeList, themes } from "./theme.ts"; import { BrailleCanvas } from "./graphics/braille.ts"; -const VERSION = "0.6.2"; +const VERSION = "0.6.3"; function help(): void { console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript diff --git a/packages/hqtui/src/index.ts b/packages/hqtui/src/index.ts index 22b6e54..bddc7c5 100644 --- a/packages/hqtui/src/index.ts +++ b/packages/hqtui/src/index.ts @@ -16,7 +16,7 @@ export { App, createApp, type AppOptions, type RenderArgs, type RenderFn, type FrameStats } from "./app.ts"; export { Container, GridContainer } from "./ui.ts"; export type { - RenderContext, ContainerOptions, PanelOptions, GridOptions, CellOptions, HitRegion, HitEvent, ScrollHandlers, + RenderContext, ContainerOptions, PanelOptions, GridOptions, CellOptions, HitRegion, HitEvent, ScrollHandlers, MarkdownCopy, } from "./ui.ts"; export { dispatchHit, countClicks, DOUBLE_CLICK_MS } from "./ui.ts"; @@ -35,6 +35,7 @@ export { type BorderStyle, type BoxOptions, type Align, type Side, type Sides, } from "./surface.ts"; export { ansi, stripAnsi, moveTo, setTitle } from "./ansi.ts"; +export { clipboardSequence, markdownText } from "./markdown.ts"; // Color export { diff --git a/packages/hqtui/src/markdown.ts b/packages/hqtui/src/markdown.ts new file mode 100644 index 0000000..c497ed8 --- /dev/null +++ b/packages/hqtui/src/markdown.ts @@ -0,0 +1,66 @@ +import { stripAnsi } from "./ansi.ts"; +import { toSpanLines, type RichText } from "./richtext.ts"; + +/** Plain labels remain literal Markdown, including brackets, pipes and HTML. */ +export function markdownText(value: string): string { + return stripAnsi(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "") + .replace(/([\\`*_{}\[\]<>#|!])/g, "\\$1"); +} + +/** Semantic summaries, collected before wrapping, clipping or scrolling. */ +export class MarkdownSummary { + private blocks: Array string)> = []; + + add(block: string | (() => string)): void { this.blocks.push(block); } + + child(): MarkdownSummary { + const child = new MarkdownSummary(); + this.add(() => child.body()); + return child; + } + + text(value: RichText): void { + this.add(markdownText(toSpanLines(value).map((line) => line.map((s) => s.text).join("")).join("\n"))); + } + + values(rows: Array<{ label: string; value: string }>): void { + this.add(rows.filter((row) => row.label || row.value).map(({ label, value }) => { + const key = markdownText(label.replace(/:\s*$/, "")).replace(/\s*\n\s*/g, " "); + const content = markdownText(value).replace(/\n/g, "\n "); + return key ? `- **${key}:** ${content}` : `- ${content}`; + }).join("\n")); + } + + series(values: number[], label = "Series", format: (value: number) => string = String): void { + const finite = values.filter(Number.isFinite); + if (!finite.length) { this.values([{ label, value: "No samples" }]); return; } + const latest = values.at(-1); + const min = finite.reduce((a, b) => Math.min(a, b), Infinity); + const max = finite.reduce((a, b) => Math.max(a, b), -Infinity); + this.values([{ label, value: `Latest: ${latest !== undefined && Number.isFinite(latest) ? format(latest) : "unavailable"}; min: ${format(min)}; max: ${format(max)}; ${finite.length} samples` }]); + } + + body(): string { + return this.blocks.map((block) => typeof block === "function" ? block() : block) + .filter((block) => block.trim()).join("\n\n"); + } + + document(title?: string, subtitle?: string, context?: string, footer?: string): string { + return [ + `## ${markdownText(title || "Summary").replace(/\s*\n\s*/g, " ")}`, + context ? markdownText(context) : "", + subtitle ? markdownText(subtitle) : "", + this.body(), + footer ? markdownText(footer) : "", + ].filter(Boolean).join("\n\n") + "\n"; + } +} + +/** OSC 52 sets the invoking terminal's clipboard, including over SSH. */ +export function clipboardSequence(text: string, tmux = false): string { + const payload = Buffer.from(text, "utf8").toString("base64"); + // Fail explicitly instead of silently truncating an exported summary. + if (payload.length > 100_000) throw new RangeError("Markdown exceeds the terminal clipboard limit."); + const sequence = `\x1b]52;c;${payload}\x07`; + return tmux ? `\x1bPtmux;${sequence.replace(/\x1b/g, "\x1b\x1b")}\x1b\\` : sequence; +} diff --git a/packages/hqtui/src/surface.ts b/packages/hqtui/src/surface.ts index 7a17dba..5d13c57 100644 --- a/packages/hqtui/src/surface.ts +++ b/packages/hqtui/src/surface.ts @@ -145,6 +145,8 @@ export interface BoxOptions extends Style { /** Right-aligned text on the top border, e.g. a value or a hint. */ subtitle?: string; subtitleColor?: Color; + /** Cells reserved at the right of the title row for header actions. */ + titleRightPadding?: number; /** Paint the interior with `bg` before drawing. */ fill?: boolean; /** @@ -432,8 +434,9 @@ export class Surface { // Measured before the title is drawn: both share the top border row, and // the title used to be truncated against the full width and then painted // over by the subtitle. + const headerWidth = Math.max(0, w - Math.max(0, options.titleRightPadding ?? 0)); const subtitle = options.subtitle ? ` ${options.subtitle} ` : ""; - const subtitleWidth = subtitle && stringWidth(subtitle) + 4 < w ? stringWidth(subtitle) : 0; + const subtitleWidth = subtitle && stringWidth(subtitle) + 4 < headerWidth ? stringWidth(subtitle) : 0; if (options.title && sides.top) { const titleColor = options.titleColor ?? this.theme.title; @@ -444,7 +447,7 @@ export class Surface { // straddling the boundary bisects it, leaving an orphaned half-character. // Both labels carry a space of padding, and those two spaces may share a // column, so the region ends one past the subtitle when there is one. - const limit = subtitleWidth > 0 ? w - 1 - subtitleWidth : w - 2; + const limit = subtitleWidth > 0 ? headerWidth - 1 - subtitleWidth : headerWidth - 2; const room = Math.max(0, limit - 2); const shown = truncate(label, room); const tw = stringWidth(shown); @@ -453,12 +456,12 @@ export class Surface { ? 2 : align === "right" ? Math.max(2, limit - tw) - : Math.max(2, Math.min(limit - tw, Math.floor((w - tw) / 2))); + : Math.max(2, Math.min(limit - tw, Math.floor((headerWidth - tw) / 2))); this.text(tx, 0, shown, { fg: titleColor, bg, attrs: 1 /* bold */ }); } if (subtitleWidth > 0 && sides.top) { - this.text(w - 2 - subtitleWidth, 0, subtitle, { + this.text(headerWidth - 2 - subtitleWidth, 0, subtitle, { fg: options.subtitleColor ?? this.theme.muted, bg, }); diff --git a/packages/hqtui/src/testing.ts b/packages/hqtui/src/testing.ts index f65b666..87405d4 100644 --- a/packages/hqtui/src/testing.ts +++ b/packages/hqtui/src/testing.ts @@ -8,6 +8,8 @@ import { CONTINUATION, cellText } from "./unicode.ts"; import { DEFAULT_COLOR, type Color } from "./color.ts"; export interface RenderOptions { + copyMarkdown?: boolean; + markdownContext?: string; width?: number; height?: number; theme?: Theme | ThemeName | string; @@ -30,6 +32,8 @@ export interface CellSnapshot { } export interface RenderedScreen { + /** Markdown delivered by copy controls. Tests never touch the clipboard. */ + copied: string[]; width: number; height: number; buffer: FrameBuffer; @@ -100,6 +104,7 @@ export function renderToScreen( const overlays: ((root: Surface) => void)[] = []; const regions: HitRegion[] = []; + const copied: string[] = []; let focusCursor = 0; const ctx: RenderContext = { theme, @@ -111,6 +116,9 @@ export function renderToScreen( focusIndex: options.focus ?? 0, collapseBorders: options.collapseBorders ?? false, reducedMotion: options.reducedMotion ?? false, + copyMarkdown: options.copyMarkdown, + markdownContext: options.markdownContext, + copyText: (text) => { copied.push(typeof text === "function" ? text() : text); }, registerFocus: () => { const index = focusCursor++; return { index, focused: index === (options.focus ?? 0) }; @@ -130,6 +138,7 @@ export function renderToScreen( for (const overlay of overlays) overlay(root); return { + copied, width, height, buffer, diff --git a/packages/hqtui/src/ui.ts b/packages/hqtui/src/ui.ts index cab51b7..f337386 100644 --- a/packages/hqtui/src/ui.ts +++ b/packages/hqtui/src/ui.ts @@ -13,6 +13,10 @@ import { BrailleCanvas } from "./graphics/braille.ts"; import { drawCanvas, type CanvasOptions } from "./graphics/canvas.ts"; import type { CountryOutline } from "./graphics/world.ts"; import * as W from "./widgets/index.ts"; +import { MarkdownSummary } from "./markdown.ts"; +import { resolveSides } from "./surface.ts"; + +export type MarkdownCopy = boolean | string | (() => string); export interface HitRegion { rect: Rect; @@ -111,7 +115,12 @@ export interface RenderContext { * redraws (the spinner) draw a still frame and ask for nothing more. */ reducedMotion: boolean; - registerFocus(action?: () => void): FocusRegistration; + registerFocus(action?: () => void, consumeKey?: boolean): FocusRegistration; + /** Opt in to summary copy controls on panels throughout this view. */ + copyMarkdown?: boolean; + /** Context included in automatic exports, e.g. host, range and source. */ + markdownContext?: string; + copyText?: (text: string | (() => string)) => void; hit(region: HitRegion): void; overlay(draw: (root: Surface) => void, options?: OverlayOptions): void; invalidate(): void; @@ -186,6 +195,8 @@ export interface ContainerOptions { } export interface PanelOptions extends ContainerOptions { + /** true exports summary widgets; a string/callback supplies custom Markdown. */ + copyMarkdown?: MarkdownCopy; title?: string; titleAlign?: Align; titleColor?: Color; @@ -228,6 +239,7 @@ export interface CellOptions { * layout once and draws — which is why `"1fr"` works without a retained tree. */ export class Container { + private summary?: MarkdownSummary; readonly surface: Surface; readonly ctx: RenderContext; readonly direction: "row" | "column"; @@ -241,7 +253,9 @@ export class Container { ctx: RenderContext, direction: "row" | "column" = "column", options: ContainerOptions = {}, + summary?: MarkdownSummary, ) { + this.summary = summary; this.surface = surface; this.ctx = ctx; this.direction = direction; @@ -339,8 +353,9 @@ export class Container { /** A horizontal container. Children default to equal shares. */ row(options: ContainerOptions = {}, build?: (row: Container) => void): this { + const summary = this.summary?.child(); return this.add((surface) => { - const container = new Container(surface, this.ctx, "row", options); + const container = new Container(surface, this.ctx, "row", options, summary); build?.(container); container.flush(); }, this.sizeOf(options, "fill"), options.bordered ?? false); @@ -348,8 +363,9 @@ export class Container { /** A vertical container. */ column(options: ContainerOptions = {}, build?: (column: Container) => void): this { + const summary = this.summary?.child(); return this.add((surface) => { - const container = new Container(surface, this.ctx, "column", options); + const container = new Container(surface, this.ctx, "column", options, summary); build?.(container); container.flush(); }, this.sizeOf(options, "fill"), options.bordered ?? false); @@ -364,8 +380,9 @@ export class Container { * }); */ grid(options: GridOptions = {}, build?: (grid: GridContainer) => void): this { + const summary = this.summary?.child(); return this.add((surface) => { - const container = new GridContainer(surface, this.ctx, options); + const container = new GridContainer(surface, this.ctx, options, summary); build?.(container); container.flush(); }, this.sizeOf(options, "fill"), options.bordered ?? false); @@ -374,6 +391,9 @@ export class Container { /** A bordered panel. The callback receives its interior as a column. */ panel(options: PanelOptions = {}, build?: (panel: Container) => void): this { const focus = options.focusable ? this.ctx.registerFocus() : undefined; + const copy = options.copyMarkdown ?? this.ctx.copyMarkdown ?? false; + const summary = options.copyMarkdown !== false && (copy || this.summary) ? new MarkdownSummary() : undefined; + if (summary) this.summary?.add(() => summary.body() ? summary.document(options.title, options.subtitle, undefined, options.footer) : ""); return this.add((surface) => { // Registered before the children draw, so a table inside the panel is // tested first and the panel only answers for the cells nothing else did. @@ -382,12 +402,16 @@ export class Container { this.ctx.hit({ rect: surface.hitRect(), onClick: (x, y, button, clicks = 1) => onClick(x, y, button, clicks) }); } const focused = options.focused ?? focus?.focused ?? false; + const canCopy = copy !== false && !!this.ctx.copyText && surface.width >= 9 + && (options.border ?? "rounded") !== "none" && resolveSides(options.sides).top; + const copyWidth = surface.width >= 18 ? 6 : 3; const boxOptions: BoxOptions = { title: options.title, titleAlign: options.titleAlign, titleColor: options.titleColor, subtitle: options.subtitle, subtitleColor: options.subtitleColor, + titleRightPadding: canCopy ? copyWidth + 2 : 0, footer: options.footer, border: options.border ?? "rounded", ...(options.sides === undefined ? {} : { sides: options.sides }), @@ -399,9 +423,23 @@ export class Container { const container = new Container(interior, this.ctx, "column", { gap: options.gap, padding: options.padding ?? [0, 1], - }); + }, summary); build?.(container); container.flush(); + // Tables, logs and raw row widgets do not contribute to this summary. + // No empty icon on a data-only pane. Custom Markdown can describe it. + if (canCopy && (copy !== true || summary?.body())) { + const action = () => this.ctx.copyText?.(() => typeof copy === "function" ? copy() + : typeof copy === "string" ? copy + : summary!.document(options.title, options.subtitle, this.ctx.markdownContext, options.footer)); + const copyFocus = this.ctx.registerFocus(action, true); + const target = surface.sub(surface.width - copyWidth - 2, 0, copyWidth, 1); + const glyph = this.ctx.capabilities.unicode ? "⧉" : "C"; + W.drawButton(target, { label: copyWidth === 6 ? `${glyph} MD` : glyph, variant: "ghost", focused: copyFocus.focused }); + this.ctx.hit({ rect: target.hitRect(), onClick: (_x, _y, button) => { if (button === "left") action(); } }); + } else if (canCopy) { + surface.box({ ...boxOptions, titleRightPadding: 0, fill: false }); + } }, this.sizeOf(options, "fill"), (options.border ?? "rounded") !== "none"); } @@ -432,6 +470,7 @@ export class Container { * paragraph reserves the rows it will actually occupy once wrapped. */ text(content: RichText, options: W.TextOptions & ContainerOptions = {}): this { + this.summary?.text(content); const lines = isRich(content) ? (options.wrap ? wrapRich(content, this.crossWidth) : toSpanLines(content)).length : (options.wrap ? wrap(content, this.crossWidth).length : content.split("\n").length); @@ -449,6 +488,7 @@ export class Container { } badge(options: W.BadgeOptions & ContainerOptions): this { + this.summary?.text(options.text); return this.add((s) => W.drawBadge(s, options), this.sizeOf(options, "auto", 1)); } @@ -459,6 +499,7 @@ export class Container { * `active: false` when the work is done and the line settles on a tick. */ spinner(options: Omit & { elapsed?: number } & ContainerOptions): this { + if (options.label) this.summary?.values([{ label: "Status", value: options.label }]); // Under reducedMotion the glyph holds its first frame and the app is not // asked to redraw: the line still says "busy", it just does not move. const still = this.ctx.reducedMotion; @@ -473,6 +514,7 @@ export class Container { /** Aligned label/value pairs. */ keyValues(rows: W.KeyValueRow[], options: Omit & ContainerOptions = {}): this { + this.summary?.values(rows); return this.add((s) => W.drawKeyValues(s, { ...options, rows }), this.sizeOf(options, "auto", rows.length)); } @@ -563,22 +605,28 @@ export class Container { /** `label ████████░░░ 42%` */ meter(options: W.MeterOptions & ContainerOptions): this { + this.summary?.values([{ label: options.label ?? "Value", value: options.text ?? `${Math.round((options.max ? options.value / options.max : options.value) * 100)}%` }]); return this.add((s) => W.drawMeter(s, options), this.sizeOfData(options, "auto", "max", 1)); } /** A stack or grid of meters. */ meters(items: W.MetersOptions["items"], options: Omit & ContainerOptions = {}): this { + this.summary?.values(items.map((item) => ({ label: item.label ?? "Value", value: item.text ?? `${Math.round((item.max ? item.value / item.max : item.value) * 100)}%` }))); const columns = Math.max(1, options.columns ?? 1); const rows = Math.ceil(items.length / columns); return this.add((s) => W.drawMeters(s, { ...options, items }), this.sizeOf(options, "auto", rows)); } progress(options: W.ProgressOptions & ContainerOptions): this { + this.summary?.values([{ label: options.label ?? "Progress", value: options.showCount ? `${options.value}/${options.max ?? 1}` : `${Math.round(options.value / (options.max ?? 1) * 100)}%` }]); return this.add((s) => W.drawProgress(s, options), this.sizeOfData(options, "auto", "max", 1)); } /** Braille line/area graph. Fills the space it is given. */ graph(options: W.GraphOptions & ContainerOptions): this { + for (const series of options.series ?? [{ values: options.values ?? [] }]) { + this.summary?.series(series.values, series.label, options.axisFormat); + } return this.add((s) => W.drawGraph(s, options), this.sizeOfData(options, "fill", "min-max")); } @@ -631,6 +679,8 @@ export class Container { } sparkline(options: W.SparklineOptions & ContainerOptions): this { + if (options.text) this.summary?.values([{ label: options.label ?? "Value", value: options.text }]); + else this.summary?.series(options.values, options.label); return this.add((s) => W.drawSparklineWidget(s, options), this.sizeOfData(options, "auto", "min-max", 1)); } @@ -640,6 +690,7 @@ export class Container { /** A semicircular dial. Wants at least 9x5. */ gauge(options: W.GaugeOptions & ContainerOptions): this { + this.summary?.values([{ label: "Gauge", value: options.label ?? `${Math.round(options.value * 100)}%` }]); return this.add((s) => W.drawGauge(s, options), this.sizeOf(options, "fill")); } @@ -654,6 +705,17 @@ export class Container { // ---------------------------------------------------------------- inputs + /** A compact copy-as-Markdown button for a status strip or custom pane. */ + copyButton(options: ContainerOptions & { markdown: string | (() => string); label?: string }): this { + const action = () => this.ctx.copyText?.(options.markdown); + const focus = this.ctx.registerFocus(action, true); + const label = options.label ?? (this.ctx.capabilities.unicode ? "⧉ MD" : "C MD"); + return this.add((surface) => { + W.drawButton(surface, { label, focused: focus.focused, variant: "ghost", disabled: !this.ctx.copyText }); + this.ctx.hit({ rect: surface.hitRect(), onClick: (_x, _y, button) => { if (button === "left") action(); } }); + }, this.sizeOf({ ...options, width: options.width ?? stringWidth(label) + 2 }, "auto", 1)); + } + /** A button. Pass `onPress` and it joins the Tab order automatically. */ button(options: W.ButtonOptions & ContainerOptions & { onPress?: () => void }): this { const focus = this.ctx.registerFocus(options.onPress); @@ -737,14 +799,18 @@ export class Container { // -------------------------------------------------------------- overlays /** A centred dialog. Tab/arrows move focus; Enter/Space activate; Esc dismisses. */ - modal(options: W.ModalOptions, build?: (modal: Container) => void): this { + modal(options: W.ModalOptions & { copyMarkdown?: MarkdownCopy }, build?: (modal: Container) => void): this { this.ctx.overlay((root) => { + const copy = options.copyMarkdown ?? this.ctx.copyMarkdown ?? false; + const summary = new MarkdownSummary(); + if (options.message) summary.text(options.message); + const canCopy = copy !== false && !!this.ctx.copyText && Math.min(options.width ?? 60, root.width - 4) >= 18; const buttons = options.buttons?.map((button) => { if (!button.onPress) return button; const focus = this.ctx.registerFocus(button.onPress); return { ...button, focused: button.focused ?? focus.focused }; }); - const inner = W.drawModal(root, { ...options, buttons }); + const inner = W.drawModal(root, { ...options, buttons, titleRightPadding: canCopy ? 8 : 0 }); // The whole screen belongs to the dialog while it is up. The backdrop // takes every click outside it (and dismisses, if asked to), the dialog // takes every click inside it, and only then do the buttons and whatever @@ -766,10 +832,18 @@ export class Container { }); } if (build) { - const container = new Container(inner, this.ctx, "column", { padding: [1, 1] }); + const container = new Container(inner, this.ctx, "column", { padding: [1, 1] }, summary); build(container); container.flush(); } + if (canCopy && (copy !== true || summary.body())) { + const action = () => this.ctx.copyText?.(() => typeof copy === "function" ? copy() + : typeof copy === "string" ? copy : summary.document(options.title, undefined, this.ctx.markdownContext)); + const focus = this.ctx.registerFocus(action, true); + const target = root.region({ x: interior.x + interior.width - 7, y: interior.y - 1, width: 6, height: 1 }); + W.drawButton(target, { label: this.ctx.capabilities.unicode ? "⧉ MD" : "C MD", variant: "ghost", focused: focus.focused }); + this.ctx.hit({ rect: target.hitRect(), onClick: (_x, _y, button) => { if (button === "left") action(); } }); + } }, { modal: true, onDismiss: options.onDismiss, onKey: options.onKey }); return this; } @@ -869,6 +943,7 @@ export class Container { /** Grid placement with spans. Cells are filled row-major. */ export class GridContainer { + private summary?: MarkdownSummary; private surface: Surface; private ctx: RenderContext; private options: GridOptions; @@ -878,7 +953,8 @@ export class GridContainer { bordered: boolean; }[] = []; - constructor(surface: Surface, ctx: RenderContext, options: GridOptions) { + constructor(surface: Surface, ctx: RenderContext, options: GridOptions, summary?: MarkdownSummary) { + this.summary = summary; this.surface = options.padding ? surface.inset(options.padding) : surface; this.ctx = ctx; this.options = options; @@ -918,24 +994,27 @@ export class GridContainer { /** A panel occupying the next free cell (or several, with colSpan/rowSpan). */ panel(options: PanelOptions & CellOptions = {}, build?: (panel: Container) => void): this { + const summary = this.summary?.child(); return this.push(options, (surface) => { - const container = new Container(surface, this.ctx, "column"); + const container = new Container(surface, this.ctx, "column", {}, summary); container.panel(options, build); container.flush(); }, (options.border ?? "rounded") !== "none"); } cell(options: CellOptions & ContainerOptions = {}, build?: (cell: Container) => void): this { + const summary = this.summary?.child(); return this.push(options, (surface) => { - const container = new Container(surface, this.ctx, "column", options); + const container = new Container(surface, this.ctx, "column", options, summary); build?.(container); container.flush(); }, options.bordered ?? false); } row(options: CellOptions & ContainerOptions = {}, build?: (row: Container) => void): this { + const summary = this.summary?.child(); return this.push(options, (surface) => { - const container = new Container(surface, this.ctx, "row", options); + const container = new Container(surface, this.ctx, "row", options, summary); build?.(container); container.flush(); }, options.bordered ?? false); diff --git a/packages/hqtui/src/widgets/controls.ts b/packages/hqtui/src/widgets/controls.ts index e84a405..d453ee8 100644 --- a/packages/hqtui/src/widgets/controls.ts +++ b/packages/hqtui/src/widgets/controls.ts @@ -184,6 +184,7 @@ export function drawTabs(surface: Surface, options: TabsOptions): void { } export interface ModalOptions { + titleRightPadding?: number; title?: string; message?: string; width?: number; @@ -253,6 +254,7 @@ export function drawModal(root: Surface, options: ModalOptions): Surface { const surface = root.sub(x, y, width, height); const inner = surface.box({ title: options.title, + titleRightPadding: options.titleRightPadding, titleAlign: options.align ?? "center", border: "rounded", borderColor: options.color ?? theme.borderFocused, diff --git a/packages/hqtui/test/markdown-copy.test.ts b/packages/hqtui/test/markdown-copy.test.ts new file mode 100644 index 0000000..ac0b9e8 --- /dev/null +++ b/packages/hqtui/test/markdown-copy.test.ts @@ -0,0 +1,145 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { App } from "../src/app.ts"; +import { clipboardSequence } from "../src/markdown.ts"; +import { renderToScreen } from "../src/testing.ts"; + +test("copy exports full semantic values and context, excluding clipped data rows", () => { + const value = "Long value that cannot fit in this narrow summary pane"; + const screen = renderToScreen(({ ui }) => ui.panel({ title: "Status", subtitle: "30 days", footer: "Partial data" }, (p) => { + p.text([{ text: "Connected ", bold: true }, { text: "but incomplete" }]); + p.keyValues([{ label: "Provider:", value }, { label: "Error", value: "payouts: unavailable" }]); + p.table({ columns: [{ key: "value", title: "Row" }], rows: [{ value: "DO NOT EXPORT ROWS" }] }); + }), { width: 38, height: 5, copyMarkdown: true, markdownContext: "CrawlProof · 1m · humans" }); + assert.equal(screen.copied.length, 0); + const icon = screen.find("⧉ MD")!; + assert.ok(icon); + assert.ok(screen.click(icon.x, icon.y)); + assert.equal(screen.copied[0], `## Status\n\nCrawlProof · 1m · humans\n\n30 days\n\nConnected but incomplete\n\n- **Provider:** ${value}\n- **Error:** payouts: unavailable\n\nPartial data\n`); + assert.ok(!screen.text().includes(value), "the export must not scrape the clipped frame"); +}); + +test("copy icon is opt-in; data-only and disabled panels keep their headers", () => { + for (const copyMarkdown of [undefined, false]) { + const screen = renderToScreen(({ ui }) => ui.panel({ title: "Summary", copyMarkdown }, (p) => p.text("Info"))); + assert.ok(!screen.contains("⧉")); + } + const view = ({ ui }: Parameters[0]>[0]) => ui.panel({ title: "Rows", subtitle: "complete" }, (p) => p.table({ columns: [{ key: "id", title: "ID" }], rows: [{ id: "1" }] })); + assert.equal(renderToScreen(view, { width: 30, copyMarkdown: true }).text(), renderToScreen(view, { width: 30 }).text()); + assert.ok(!renderToScreen(({ ui }) => ui.panel({ title: "Private", copyMarkdown: false }, (p) => p.text("Info")), { copyMarkdown: true }).contains("⧉")); +}); + +test("explicit Markdown is computed only on activation; right click does not copy", () => { + let calls = 0; + let value = "ready"; + const screen = renderToScreen(({ ui }) => ui.panel({ title: "Feature", copyMarkdown: () => { calls++; return `## Feature\n\n${value}\n`; } })); + assert.equal(calls, 0); + value = "updated"; + const icon = screen.find("⧉ MD")!; + screen.click(icon.x, icon.y, { button: "right" }); + assert.equal(calls, 0); + screen.click(icon.x, icon.y); + assert.deepEqual(screen.copied, ["## Feature\n\nupdated\n"]); +}); + +test("nested summaries, graph summaries and Markdown punctuation remain readable", () => { + const screen = renderToScreen(({ ui }) => ui.panel({ title: "a[b]", copyMarkdown: true }, (p) => { + p.column({ size: 2 }, (child) => child.keyValues([{ label: "name_*", value: "\nnext" }])); + p.graph({ series: [{ label: "income", values: [1, 4, 2] }], axisFormat: (n) => `$${n}` }); + })); + const icon = screen.find("⧉ MD")!; + screen.click(icon.x, icon.y); + assert.match(screen.copied[0], /name\\_\\\*/); + assert.ok(screen.copied[0].includes("\\\n next")); + assert.ok(screen.copied[0].includes("Latest: $2; min: $1; max: $4; 3 samples")); +}); + +test("nested grids preserve summary order and opt-outs exclude content from parent exports", () => { + const screen = renderToScreen(({ ui }) => ui.panel({ title: "Outer", copyMarkdown: true }, (p) => { + p.text("Before"); + p.grid({ columns: 2 }, (g) => { + g.panel({ title: "Nested" }, (inner) => inner.text("Nested info")); + g.panel({ title: "Private", copyMarkdown: false }, (inner) => inner.text("DO NOT EXPORT")); + }); + p.text("After"); + })); + const icon = screen.find("⧉ MD")!; + screen.click(icon.x, icon.y); + assert.match(screen.copied[0], /Before[\s\S]*Nested info[\s\S]*After/); + assert.ok(!screen.copied[0].includes("DO NOT EXPORT")); +}); + +test("copy controls remain inside narrow/ASCII panes, and leave missing top borders alone", () => { + for (const width of [9, 12, 18, 30]) { + const screen = renderToScreen(({ ui }) => ui.panel({ title: "Long title with wide 字 glyphs", subtitle: "Subtitle", copyMarkdown: "# Summary" }), { width, height: 4, capabilities: { unicode: false } }); + const icon = screen.find(width >= 18 ? "C MD" : "C")!; + assert.ok(icon); + assert.equal(screen.cell(width - 1, 0).char, "╮"); + screen.click(icon.x, icon.y); + assert.deepEqual(screen.copied, ["# Summary"]); + assert.ok(screen.regions.every((r) => r.rect.x >= 0 && r.rect.x + r.rect.width <= width)); + } + const borderless = renderToScreen(({ ui }) => ui.panel({ sides: ["bottom"], copyMarkdown: true }, (p) => p.text("Text"))); + assert.ok(!borderless.contains("⧉")); +}); + +test("modal help and standalone status controls export without closing the dialog", () => { + let closed = false; + const screen = renderToScreen(({ ui }) => { + ui.copyButton({ markdown: "## Status\n\nReady" }); + ui.modal({ title: "Help", message: "Read this\nThen do that", onDismiss: () => { closed = true; } }); + }, { copyMarkdown: true }); + const icon = screen.regions.at(-1)!; + screen.click(icon.rect.x, icon.rect.y); + assert.equal(closed, false); + assert.deepEqual(screen.copied, ["## Help\n\nRead this\nThen do that\n"]); +}); + +test("OSC 52 encodes UTF-8 and tmux wrapping without evaluating clipboard content", () => { + const content = "## Status\n\n✓ Ready — $(do-not-run)"; + const encoded = Buffer.from(content).toString("base64"); + assert.equal(clipboardSequence(content), `\x1b]52;c;${encoded}\x07`); + assert.equal(clipboardSequence(content, true), `\x1bPtmux;\x1b\x1b]52;c;${encoded}\x07\x1b\\`); + assert.throws(() => clipboardSequence("x".repeat(100_000)), /limit/); +}); + +test("Tab/Enter copy once without leaking into row activation or Space shortcuts", async (t) => { + const input = new PassThrough(); + const output = new PassThrough(); + output.resume(); + Object.assign(output, { columns: 80, rows: 24 }); + const copied: string[] = []; + const app = new App({ input: input as never, output: output as never, installExitHandlers: false, quitKeys: [], copyMarkdown: true, clipboard: (text) => { copied.push(text); } }); + t.after(() => app.stop()); + const keys: string[] = []; + app.on("key", (e) => keys.push(e.name)); + app.render(({ ui }) => ui.panel({ title: "Info" }, (p) => p.text("Ready"))); + void app.start(); + const key = async (bytes: string) => { input.write(bytes); await new Promise((resolve) => setTimeout(resolve, 0)); app.frame(); }; + await key("\r"); + assert.deepEqual(keys, ["enter"], "ordinary row Enter must still work before the user focuses Copy"); + await key("\t"); + await key("\r"); + await key(" "); + assert.deepEqual(keys, ["enter"]); + assert.deepEqual(copied, ["## Info\n\nReady\n", "## Info\n\nReady\n"]); + await key("\x1b[B"); + await key("\r"); + assert.deepEqual(keys, ["enter", "down", "enter"], "returning to row navigation releases copy focus"); + assert.equal(copied.length, 2); +}); + +test("clipboard failures are contained and shown instead of crashing the application", async (t) => { + const output = new PassThrough(); + let drawn = ""; + output.on("data", (data) => { drawn += data.toString(); }); + Object.assign(output, { columns: 80, rows: 24 }); + const app = new App({ output: output as never, installExitHandlers: false, clipboard: () => { throw new Error("clipboard denied"); } }); + t.after(() => app.stop()); + await app.copyToClipboard("# Summary"); + app.frame(); + assert.ok(drawn.includes("Copy failed: clipboard denied")); + await app.copyToClipboard(() => { throw new Error("summary failed"); }); + app.frame(); +});