pi-copy 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # pi-copy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A pi extension that copies **one part** of the last answer so it pastes cleanly: a code block exactly as written (a long command continued with `\` pastes into a terminal and runs), or the prose as plain text (one line per paragraph, ready for an email).
4
+
5
+ Pi's own `/copy` (Ctrl+X) copies the whole last answer as markdown source, fences and marks included; drag-selecting copies the rendered screen, wraps and borders included. pi-copy reads the answer's source text from the session instead, never the screen.
6
+
7
+ ## Use
8
+
9
+ - `/copy-part` opens a picker of the answer's parts. Enter copies the chosen part; Escape copies nothing.
10
+ - **alt+c** does the same, except that when the answer has exactly one code block it copies that block at once.
11
+ - After a copy, a one-line notice names what was copied and its size, such as `Copied code block (1 line, 148 chars)`. With no answer yet it says `No answer to copy yet.`
12
+
13
+ ## The parts
14
+
15
+ - **Each code block**, in order, labelled with its language and its first line. Copied as its exact text: no fences, no indent, no trailing newline. A block inside a list item counts too.
16
+ - **The prose**: everything outside the code blocks, as plain text. Paragraphs are one line each, separated by a blank line; bold, italic, inline code and strike marks are removed; a link becomes `text (url)`, or just the address when the two are the same; headings are their text on a line of their own; list items keep `- ` or `1. ` with two spaces per nesting level; a blockquote loses its `>`; a table is copied as its markdown source rows.
17
+ - **All**: the whole answer, the prose as above with each code block's text in place.
18
+
19
+ An answer with no code blocks has only the prose part; one with no prose has only its code blocks.
20
+
21
+ Only the last answer is read. An earlier answer can still be copied whole from `/tree` with Ctrl+X.
22
+
23
+ ## Design
24
+
25
+ - Parsing uses `marked`'s lexer, the parser pi draws messages with, carried as this package's own dependency (pinned to the version pi uses) because pi doesn't provide it to extensions.
26
+ - The copy goes through pi's `copyToClipboard`, which also reaches the local clipboard from tmux and over SSH through OSC 52.
27
+ - `src/parts.ts` is the pure splitting and plain-text rendering; `src/index.ts` is the thin pi wiring.
28
+
29
+ ## Tests
30
+
31
+ `node --test src/*.test.ts`: the parts and each part's copied text (continued commands, a block in a list, paragraphs, inline marks, links, headings, nested lists, blockquotes, tables, the notice), and the wiring against a fake pi (alt+c's one-block shortcut, the picker, Escape, no answer, an aborted answer skipped).
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ pi install npm:pi-copy
37
+ ```
38
+
39
+ Or try it against a live session without installing:
40
+
41
+ ```sh
42
+ pi -e ./packages/pi-copy/src/index.ts
43
+ ```
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "pi-copy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "Copy one part of the last pi answer from its source text: a code block exactly as written, or the prose as plain text, through /copy-part and alt+c.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/subaud-tools/pi-extensions.git",
10
+ "directory": "packages/pi-copy"
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "pi-extension",
15
+ "pi-coding-agent",
16
+ "copy",
17
+ "clipboard",
18
+ "markdown"
19
+ ],
20
+ "files": [
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/index.ts"
27
+ ]
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit -p tsconfig.json",
31
+ "test": "node --test src/*.test.ts"
32
+ },
33
+ "dependencies": {
34
+ "marked": "18.0.11"
35
+ },
36
+ "devDependencies": {
37
+ "@earendil-works/pi-coding-agent": "^0.84.3",
38
+ "typescript": "^5.6.0"
39
+ }
40
+ }
@@ -0,0 +1,112 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createCopy } from "./index.ts";
4
+
5
+ const assistant = (text: string) => ({
6
+ type: "message",
7
+ message: { role: "assistant", content: [{ type: "text", text }], stopReason: "stop" },
8
+ });
9
+ const user = (text: string) => ({ type: "message", message: { role: "user", content: text } });
10
+
11
+ // A fake pi capturing the command and shortcut, and a fake ctx whose branch,
12
+ // select answer, notices and clipboard are all observable.
13
+ function makeFixture(branch: any[], choose: (labels: string[]) => string | undefined = () => undefined) {
14
+ const commands = new Map<string, { handler: (args: string, ctx: any) => Promise<void> }>();
15
+ const shortcuts = new Map<string, { handler: (ctx: any) => Promise<void> }>();
16
+ const copied: string[] = [];
17
+ const notices: string[] = [];
18
+ const selects: string[][] = [];
19
+
20
+ const pi = {
21
+ registerCommand(name: string, options: any) {
22
+ commands.set(name, options);
23
+ },
24
+ registerShortcut(key: string, options: any) {
25
+ shortcuts.set(key, options);
26
+ },
27
+ };
28
+ createCopy(pi, {
29
+ copy: async (text: string) => {
30
+ copied.push(text);
31
+ },
32
+ });
33
+
34
+ const ctx = {
35
+ sessionManager: { getBranch: () => branch },
36
+ ui: {
37
+ async select(_title: string, labels: string[]) {
38
+ selects.push(labels);
39
+ return choose(labels);
40
+ },
41
+ notify(message: string) {
42
+ notices.push(message);
43
+ },
44
+ },
45
+ };
46
+
47
+ return {
48
+ copied,
49
+ notices,
50
+ selects,
51
+ altC: () => shortcuts.get("alt+c")!.handler(ctx),
52
+ copyPart: () => commands.get("copy-part")!.handler("", ctx),
53
+ };
54
+ }
55
+
56
+ const oneBlock = "Cut the release:\n\n```bash\ngit tag pi-copy-v0.1.0 && git push --tags\n```";
57
+ const twoBlocks = "Build:\n\n```sh\nnpm pack --workspace packages/pi-copy\n```\n\nThen:\n\n```sh\nnpm publish pi-copy-0.1.0.tgz\n```";
58
+
59
+ test("alt+c with one code block copies it without the picker", async () => {
60
+ const f = makeFixture([user("how do I tag it?"), assistant(oneBlock)]);
61
+
62
+ await f.altC();
63
+
64
+ assert.equal(f.selects.length, 0);
65
+ assert.deepEqual(f.copied, ["git tag pi-copy-v0.1.0 && git push --tags"]);
66
+ assert.deepEqual(f.notices, ["Copied code block (1 line, 41 chars)"]);
67
+ });
68
+
69
+ test("alt+c with two code blocks opens the picker and copies the choice", async () => {
70
+ const f = makeFixture([assistant(twoBlocks)], (labels) => labels[1]);
71
+
72
+ await f.altC();
73
+
74
+ assert.equal(f.selects.length, 1);
75
+ assert.deepEqual(f.copied, ["npm publish pi-copy-0.1.0.tgz"]);
76
+ });
77
+
78
+ test("/copy-part opens the picker even with one code block", async () => {
79
+ const f = makeFixture([assistant(oneBlock)], (labels) => labels.find((l) => l.startsWith("Prose")));
80
+
81
+ await f.copyPart();
82
+
83
+ assert.equal(f.selects.length, 1);
84
+ assert.deepEqual(f.copied, ["Cut the release:"]);
85
+ });
86
+
87
+ test("Escape in the picker copies nothing", async () => {
88
+ const f = makeFixture([assistant(twoBlocks)], () => undefined);
89
+
90
+ await f.copyPart();
91
+
92
+ assert.deepEqual(f.copied, []);
93
+ assert.deepEqual(f.notices, []);
94
+ });
95
+
96
+ test("no answer yet notifies and copies nothing", async () => {
97
+ const f = makeFixture([user("hello")]);
98
+
99
+ await f.altC();
100
+
101
+ assert.deepEqual(f.copied, []);
102
+ assert.deepEqual(f.notices, ["No answer to copy yet."]);
103
+ });
104
+
105
+ test("an aborted empty answer is skipped for the one before it", async () => {
106
+ const aborted = { type: "message", message: { role: "assistant", content: [], stopReason: "aborted" } };
107
+ const f = makeFixture([assistant(oneBlock), user("again"), aborted]);
108
+
109
+ await f.altC();
110
+
111
+ assert.deepEqual(f.copied, ["git tag pi-copy-v0.1.0 && git push --tags"]);
112
+ });
package/src/index.ts ADDED
@@ -0,0 +1,89 @@
1
+ // pi-copy — copy one part of the last answer: a code block exactly as
2
+ // written, or the prose as plain text.
3
+ //
4
+ // The answer is read from the session (the same message pi's /copy uses),
5
+ // never from the screen, so nothing copied carries pi's wrapping or borders.
6
+ // `/copy-part` always opens a picker of the parts; alt+c copies the one code
7
+ // block at once when there is exactly one, and opens the picker otherwise.
8
+ // The copy goes through pi's own copyToClipboard, which also reaches the
9
+ // local clipboard from tmux and over SSH (OSC 52).
10
+ //
11
+ // The pure splitting lives in parts.ts; this file is the thin pi wiring.
12
+
13
+ import { notice, splitParts, type Part } from "./parts.ts";
14
+
15
+ export type Deps = {
16
+ // Injectable so the wiring is testable without pi's real clipboard.
17
+ copy?: (text: string) => Promise<void>;
18
+ };
19
+
20
+ // Lazy: a top-level import of the pi package pulls its entire runtime, which
21
+ // only resolves inside a live pi process (and breaks node --test).
22
+ async function defaultCopy(text: string): Promise<void> {
23
+ const mod: any = await import("@earendil-works/pi-coding-agent");
24
+ await mod.copyToClipboard(text);
25
+ }
26
+
27
+ // The last assistant answer's text, as pi's getLastAssistantText reads it:
28
+ // an aborted message with no content is skipped.
29
+ export function lastAnswerText(branch: any[]): string | undefined {
30
+ const entry = branch
31
+ .slice()
32
+ .reverse()
33
+ .find((e) => {
34
+ const m = e?.type === "message" ? e.message : undefined;
35
+ if (m?.role !== "assistant") return false;
36
+ return !(m.stopReason === "aborted" && (m.content ?? []).length === 0);
37
+ });
38
+ if (!entry) return undefined;
39
+ let text = "";
40
+ for (const content of entry.message.content ?? []) {
41
+ if (content?.type === "text") text += content.text;
42
+ }
43
+ return text.trim() || undefined;
44
+ }
45
+
46
+ export function createCopy(pi: any, deps: Deps = {}): void {
47
+ const copy = deps.copy ?? defaultCopy;
48
+
49
+ async function run(ctx: any, alwaysPick: boolean): Promise<void> {
50
+ const text = lastAnswerText(ctx.sessionManager.getBranch());
51
+ if (!text) {
52
+ ctx.ui.notify("No answer to copy yet.", "info");
53
+ return;
54
+ }
55
+ const parts = splitParts(text);
56
+ const code = parts.filter((p) => p.kind === "code");
57
+ let part: Part | undefined;
58
+ if (!alwaysPick && code.length === 1) {
59
+ part = code[0];
60
+ } else {
61
+ const label = await ctx.ui.select(
62
+ "Copy which part?",
63
+ parts.map((p) => p.label),
64
+ );
65
+ part = parts.find((p) => p.label === label);
66
+ }
67
+ if (!part) return;
68
+ try {
69
+ await copy(part.text);
70
+ ctx.ui.notify(notice(part), "info");
71
+ } catch (error) {
72
+ ctx.ui.notify(`pi-copy: could not copy: ${String(error)}`, "error");
73
+ }
74
+ }
75
+
76
+ pi.registerCommand("copy-part", {
77
+ description: "Copy one part of the last answer: a code block as written, or the prose as plain text",
78
+ handler: async (_args: string, ctx: any) => run(ctx, true),
79
+ });
80
+
81
+ pi.registerShortcut("alt+c", {
82
+ description: "Copy a part of the last answer (the one code block at once)",
83
+ handler: async (ctx: any) => run(ctx, false),
84
+ });
85
+ }
86
+
87
+ export default function (pi: any) {
88
+ createCopy(pi);
89
+ }
@@ -0,0 +1,158 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { notice, splitParts } from "./parts.ts";
4
+
5
+ const prose = (md: string) => splitParts(md).find((p) => p.kind === "prose")!.text;
6
+
7
+ test("a multi-line command with \\ continuations copies byte for byte", () => {
8
+ const command = [
9
+ "gh release create pi-copy-v0.1.0 \\",
10
+ ' --repo subaud-tools/pi-extensions \\',
11
+ ' --title "pi-copy 0.1.0" \\',
12
+ " --notes-file CHANGELOG.md --verify-tag",
13
+ ].join("\n");
14
+ const answer = `Run this to cut the release:\n\n\`\`\`bash\n${command}\n\`\`\`\n\nThen watch the workflow.`;
15
+
16
+ const parts = splitParts(answer);
17
+
18
+ assert.equal(parts[0]!.kind, "code");
19
+ assert.equal(parts[0]!.text, command);
20
+ assert.match(parts[0]!.label, /bash/);
21
+ assert.match(parts[0]!.label, /gh release create pi-copy-v0\.1\.0/);
22
+ });
23
+
24
+ test("a code block inside a list item is a part copied without the list indent", () => {
25
+ const answer = [
26
+ "Two steps:",
27
+ "",
28
+ "1. Build the tarball:",
29
+ "",
30
+ " ```sh",
31
+ " npm pack --workspace packages/pi-copy",
32
+ " ls *.tgz",
33
+ " ```",
34
+ "",
35
+ "2. Publish it.",
36
+ ].join("\n");
37
+
38
+ const code = splitParts(answer).filter((p) => p.kind === "code");
39
+
40
+ assert.equal(code.length, 1);
41
+ assert.equal(code[0]!.text, "npm pack --workspace packages/pi-copy\nls *.tgz");
42
+ });
43
+
44
+ test("a paragraph written across several source lines becomes one line", () => {
45
+ const answer = [
46
+ "Hi Dana,",
47
+ "",
48
+ "Thanks for sending the deck over. I went through it",
49
+ "last night and the pricing section reads well,",
50
+ "though slide 7 could lose a bullet.",
51
+ "",
52
+ "Can we talk Thursday?",
53
+ ].join("\n");
54
+
55
+ assert.equal(
56
+ prose(answer),
57
+ "Hi Dana,\n\nThanks for sending the deck over. I went through it last night and the pricing section reads well, though slide 7 could lose a bullet.\n\nCan we talk Thursday?",
58
+ );
59
+ });
60
+
61
+ test("bold, italic, inline code and strike marks are removed", () => {
62
+ const answer = "The **release** is _ready_: run `npm pack` ~~twice~~ once.";
63
+
64
+ assert.equal(prose(answer), "The release is ready: run npm pack twice once.");
65
+ });
66
+
67
+ test("a link becomes its text and address, a bare URL just the address", () => {
68
+ const answer = "See [the deck](https://x.io/d) or https://x.io/notes for detail.";
69
+
70
+ assert.equal(prose(answer), "See the deck (https://x.io/d) or https://x.io/notes for detail.");
71
+ });
72
+
73
+ test("a heading becomes its text on a line of its own", () => {
74
+ const answer = "## Next steps\n\nShip it on Friday.";
75
+
76
+ assert.equal(prose(answer), "Next steps\n\nShip it on Friday.");
77
+ });
78
+
79
+ test("nested ordered and unordered lists keep markers, two spaces per level", () => {
80
+ const answer = [
81
+ "1. Prepare the release",
82
+ " - bump the **version**",
83
+ " - update the changelog",
84
+ "2. Tag it",
85
+ ].join("\n");
86
+
87
+ assert.equal(
88
+ prose(answer),
89
+ "1. Prepare the release\n - bump the version\n - update the changelog\n2. Tag it",
90
+ );
91
+ });
92
+
93
+ test("a blockquote becomes its text without the > mark", () => {
94
+ const answer = "> Ship the smallest correct change.\n> Then stop.";
95
+
96
+ assert.equal(prose(answer), "Ship the smallest correct change. Then stop.");
97
+ });
98
+
99
+ test("a table keeps its markdown source rows", () => {
100
+ const table = "| Package | Status |\n|---|---|\n| pi-copy | new |";
101
+ const answer = `Where things stand:\n\n${table}`;
102
+
103
+ assert.equal(prose(answer), `Where things stand:\n\n${table}`);
104
+ });
105
+
106
+ test("an answer with only prose has the prose part alone", () => {
107
+ const parts = splitParts("Hi Dana,\n\nThe deck looks good.\n\nCourt");
108
+
109
+ assert.deepEqual(
110
+ parts.map((p) => p.kind),
111
+ ["prose"],
112
+ );
113
+ });
114
+
115
+ test("an answer with only a code block has only the block", () => {
116
+ const parts = splitParts("```bash\nnpm publish --access public\n```");
117
+
118
+ assert.deepEqual(
119
+ parts.map((p) => p.kind),
120
+ ["code"],
121
+ );
122
+ assert.equal(parts[0]!.text, "npm publish --access public");
123
+ });
124
+
125
+ test("a blank line before the closing fence leaves no trailing newline", () => {
126
+ const answer = "Run:\n\n```sh\necho ready\n\n```";
127
+
128
+ const parts = splitParts(answer);
129
+
130
+ assert.equal(parts[0]!.text, "echo ready");
131
+ assert.equal(parts[2]!.text, "Run:\n\necho ready");
132
+ });
133
+
134
+ test("parts come in order: each code block, then prose, then all with code in place", () => {
135
+ const answer = "First:\n\n```sh\nnpm ci\n```\n\nThen **test**:\n\n```sh\nnpm test\n```";
136
+
137
+ const parts = splitParts(answer);
138
+
139
+ assert.deepEqual(
140
+ parts.map((p) => p.kind),
141
+ ["code", "code", "prose", "all"],
142
+ );
143
+ assert.equal(parts[2]!.text, "First:\n\nThen test:");
144
+ assert.equal(parts[3]!.text, "First:\n\nnpm ci\n\nThen test:\n\nnpm test");
145
+ });
146
+
147
+ test("notice names a one-line part and its size", () => {
148
+ const text = "x".repeat(148);
149
+ const [part] = splitParts("```\n" + text + "\n```");
150
+
151
+ assert.equal(notice(part!), "Copied code block (1 line, 148 chars)");
152
+ });
153
+
154
+ test("notice counts lines of a multi-line part", () => {
155
+ const parts = splitParts("Hello there.\n\nSecond paragraph.");
156
+
157
+ assert.equal(notice(parts[0]!), "Copied prose (3 lines, 31 chars)");
158
+ });
package/src/parts.ts ADDED
@@ -0,0 +1,147 @@
1
+ // pi-copy parts — split an answer's markdown source into the parts the picker
2
+ // offers, and turn each into the text it copies.
3
+ //
4
+ // Parsing uses marked's lexer (the parser pi draws messages with), so a code
5
+ // block is found wherever pi would draw one, inside a list item included, and
6
+ // its text is marked's: fences and indent removed, no trailing newline.
7
+ // Prose is rendered to plain text here: one line per paragraph, inline marks
8
+ // dropped, links as `text (url)`, list markers kept, tables as source rows.
9
+
10
+ import { Lexer, type Token, type Tokens } from "marked";
11
+
12
+ export type Part = { kind: "code" | "prose" | "all"; label: string; text: string };
13
+
14
+ const PREVIEW = 60;
15
+
16
+ function preview(text: string): string {
17
+ const first = text.split("\n").find((line) => line.trim() !== "") ?? "";
18
+ const line = first.trim();
19
+ return line.length > PREVIEW ? `${line.slice(0, PREVIEW - 1)}…` : line;
20
+ }
21
+
22
+ // Every code block, in source order, wherever it sits (lists, blockquotes).
23
+ function codeBlocks(tokens: Token[], out: Tokens.Code[] = []): Tokens.Code[] {
24
+ for (const token of tokens) {
25
+ if (token.type === "code") out.push(token as Tokens.Code);
26
+ else if (token.type === "list") for (const item of (token as Tokens.List).items) codeBlocks(item.tokens, out);
27
+ else if ("tokens" in token && Array.isArray(token.tokens)) codeBlocks(token.tokens, out);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ // A code block's exact text, without the trailing newlines a blank line before the fence leaves.
33
+ function codeText(code: Tokens.Code): string {
34
+ return code.text.replace(/\n+$/, "");
35
+ }
36
+
37
+ function inline(tokens: Token[] | undefined): string {
38
+ if (!tokens) return "";
39
+ return tokens.map(inlineToken).join("");
40
+ }
41
+
42
+ function inlineToken(token: Token): string {
43
+ switch (token.type) {
44
+ case "strong":
45
+ case "em":
46
+ case "del":
47
+ return inline((token as Tokens.Strong).tokens);
48
+ case "codespan":
49
+ return (token as Tokens.Codespan).text;
50
+ case "link":
51
+ case "image": {
52
+ const link = token as Tokens.Link;
53
+ const text = link.tokens ? inline(link.tokens) : link.text;
54
+ return text === link.href || text === "" ? link.href : `${text} (${link.href})`;
55
+ }
56
+ case "br":
57
+ return "\n";
58
+ case "text": {
59
+ const text = token as Tokens.Text;
60
+ return text.tokens ? inline(text.tokens) : text.text;
61
+ }
62
+ default:
63
+ return "text" in token && typeof token.text === "string" ? token.text : token.raw;
64
+ }
65
+ }
66
+
67
+ // A paragraph's soft and hard line breaks become spaces: one line each.
68
+ function oneLine(text: string): string {
69
+ return text.replace(/[ \t]*\n[ \t]*/g, " ").trim();
70
+ }
71
+
72
+ // Blocks to plain text; withCode puts each code block's text in place.
73
+ function blocks(tokens: Token[], withCode: boolean): string[] {
74
+ const out: string[] = [];
75
+ for (const token of tokens) {
76
+ switch (token.type) {
77
+ case "paragraph":
78
+ case "heading":
79
+ case "text":
80
+ out.push(oneLine(inline((token as Tokens.Paragraph).tokens)));
81
+ break;
82
+ case "code":
83
+ if (withCode) out.push(codeText(token as Tokens.Code));
84
+ break;
85
+ case "list":
86
+ out.push(list(token as Tokens.List, 0, withCode).join("\n"));
87
+ break;
88
+ case "blockquote":
89
+ out.push(...blocks((token as Tokens.Blockquote).tokens, withCode));
90
+ break;
91
+ case "table":
92
+ out.push(token.raw.trimEnd());
93
+ break;
94
+ case "html":
95
+ out.push(token.raw.trim());
96
+ break;
97
+ default:
98
+ // space, hr, def: nothing to copy
99
+ break;
100
+ }
101
+ }
102
+ return out.filter((block) => block !== "");
103
+ }
104
+
105
+ function list(token: Tokens.List, depth: number, withCode: boolean): string[] {
106
+ const indent = " ".repeat(depth);
107
+ const start = typeof token.start === "number" ? token.start : 1;
108
+ const lines: string[] = [];
109
+ token.items.forEach((item, index) => {
110
+ const marker = token.ordered ? `${start + index}. ` : "- ";
111
+ let first = true;
112
+ for (const child of item.tokens) {
113
+ if (child.type === "list") {
114
+ lines.push(...list(child as Tokens.List, depth + 1, withCode));
115
+ continue;
116
+ }
117
+ for (const block of blocks([child], withCode)) {
118
+ // A code block keeps its own text: no marker, no indent.
119
+ if (child.type === "code") lines.push(block);
120
+ else lines.push(first ? `${indent}${marker}${block}` : `${indent} ${block}`);
121
+ if (child.type !== "code") first = false;
122
+ }
123
+ }
124
+ });
125
+ return lines;
126
+ }
127
+
128
+ export function splitParts(markdown: string): Part[] {
129
+ const tokens = new Lexer().lex(markdown);
130
+ const parts: Part[] = codeBlocks(tokens).map((code, index) => ({
131
+ kind: "code",
132
+ label: `Code block ${index + 1}${code.lang ? ` (${code.lang})` : ""}: ${preview(code.text)}`,
133
+ text: codeText(code),
134
+ }));
135
+ const prose = blocks(tokens, false).join("\n\n");
136
+ if (prose === "") return parts;
137
+ parts.push({ kind: "prose", label: `Prose: ${preview(prose)}`, text: prose });
138
+ if (parts.length > 1) parts.push({ kind: "all", label: "All of the answer", text: blocks(tokens, true).join("\n\n") });
139
+ return parts;
140
+ }
141
+
142
+ const NOUN: Record<Part["kind"], string> = { code: "code block", prose: "prose", all: "the whole answer" };
143
+
144
+ export function notice(part: Part): string {
145
+ const lines = part.text.split("\n").length;
146
+ return `Copied ${NOUN[part.kind]} (${lines} ${lines === 1 ? "line" : "lines"}, ${part.text.length} chars)`;
147
+ }