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();
+});