pi-copy 0.1.1 → 0.2.0

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,22 +1,23 @@
1
1
  # pi-copy
2
2
 
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).
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), a quoted message on its own, or the prose as plain text (one line per paragraph, ready for an email).
4
4
 
5
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
6
 
7
7
  ## Use
8
8
 
9
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.
10
+ - **alt+c** does the same, except that it copies at once when the answer has exactly one code block, or no code block and exactly one quote.
11
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
12
 
13
13
  ## The parts
14
14
 
15
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
+ - **Each quote**, in order, labelled with its first line: every blockquote not inside another, a blockquote in a list item included. Copied as plain text by the prose rules below, without the `>` marks, with any code block inside it in place. A message an answer writes for someone else, such as one under "Message for Dana:", is usually a quote.
16
17
  - **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
18
  - **All**: the whole answer, the prose as above with each code block's text in place.
18
19
 
19
- An answer with no code blocks has only the prose part; one with no prose has only its code blocks.
20
+ An answer with no code blocks or quotes has only the prose part; one with no prose has only its code blocks.
20
21
 
21
22
  Only the last answer is read. An earlier answer can still be copied whole from `/tree` with Ctrl+X.
22
23
 
@@ -28,7 +29,7 @@ Only the last answer is read. An earlier answer can still be copied whole from `
28
29
 
29
30
  ## Tests
30
31
 
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
+ `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, quote parts, tables, the notice), and the wiring against a fake pi (alt+c's one-block and one-quote shortcuts, the picker, Escape, no answer, an aborted answer skipped).
32
33
 
33
34
  ## Install
34
35
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-copy",
3
- "version": "0.1.1",
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.",
3
+ "version": "0.2.0",
4
+ "description": "Copy one part of the last pi answer from its source text: a code block exactly as written, a quoted message, or the prose as plain text, through /copy-part and alt+c.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
package/src/index.test.ts CHANGED
@@ -66,6 +66,37 @@ test("alt+c with one code block copies it without the picker", async () => {
66
66
  assert.deepEqual(f.notices, ["Copied code block (1 line, 41 chars)"]);
67
67
  });
68
68
 
69
+ const salomon =
70
+ "Message for Salomon:\n\n> Dev Fetch’s CRM now works. Please walk a pitch deck through Make it.\n\nSaved and pushed as `d31765e`.";
71
+
72
+ test("alt+c with one quote and no code block copies the quote without the picker", async () => {
73
+ const f = makeFixture([assistant(salomon)]);
74
+
75
+ await f.altC();
76
+
77
+ assert.equal(f.selects.length, 0);
78
+ assert.deepEqual(f.copied, ["Dev Fetch’s CRM now works. Please walk a pitch deck through Make it."]);
79
+ assert.deepEqual(f.notices, ["Copied quote (1 line, 68 chars)"]);
80
+ });
81
+
82
+ test("alt+c with one code block and one quote copies the code block", async () => {
83
+ const f = makeFixture([assistant(`${oneBlock}\n\n> Tell Dana it is tagged.`)]);
84
+
85
+ await f.altC();
86
+
87
+ assert.equal(f.selects.length, 0);
88
+ assert.deepEqual(f.copied, ["git tag pi-copy-v0.1.0 && git push --tags"]);
89
+ });
90
+
91
+ test("alt+c with two quotes and no code block opens the picker", async () => {
92
+ const f = makeFixture([assistant("For Dana:\n\n> Deck is ready.\n\nFor Sam:\n\n> Room is booked.")], (labels) => labels[1]);
93
+
94
+ await f.altC();
95
+
96
+ assert.equal(f.selects.length, 1);
97
+ assert.deepEqual(f.copied, ["Room is booked."]);
98
+ });
99
+
69
100
  test("alt+c with two code blocks opens the picker and copies the choice", async () => {
70
101
  const f = makeFixture([assistant(twoBlocks)], (labels) => labels[1]);
71
102
 
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // pi-copy — copy one part of the last answer: a code block exactly as
2
- // written, or the prose as plain text.
2
+ // written, a quoted message, or the prose as plain text.
3
3
  //
4
4
  // The answer is read from the session (the same message pi's /copy uses),
5
5
  // never from the screen, so nothing copied carries pi's wrapping or borders.
6
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.
7
+ // block at once when there is exactly one, or the one quote when there is no
8
+ // code block, and opens the picker otherwise.
8
9
  // The copy goes through pi's own copyToClipboard, which also reaches the
9
10
  // local clipboard from tmux and over SSH (OSC 52).
10
11
  //
@@ -54,9 +55,11 @@ export function createCopy(pi: any, deps: Deps = {}): void {
54
55
  }
55
56
  const parts = splitParts(text);
56
57
  const code = parts.filter((p) => p.kind === "code");
58
+ const quote = parts.filter((p) => p.kind === "quote");
59
+ const only = code.length === 1 ? code[0] : code.length === 0 && quote.length === 1 ? quote[0] : undefined;
57
60
  let part: Part | undefined;
58
- if (!alwaysPick && code.length === 1) {
59
- part = code[0];
61
+ if (!alwaysPick && only) {
62
+ part = only;
60
63
  } else {
61
64
  const label = await ctx.ui.select(
62
65
  "Copy which part?",
@@ -74,7 +77,7 @@ export function createCopy(pi: any, deps: Deps = {}): void {
74
77
  }
75
78
 
76
79
  pi.registerCommand("copy-part", {
77
- description: "Copy one part of the last answer: a code block as written, or the prose as plain text",
80
+ description: "Copy one part of the last answer: a code block as written, a quoted message, or the prose as plain text",
78
81
  handler: async (_args: string, ctx: any) => run(ctx, true),
79
82
  });
80
83
 
package/src/parts.test.ts CHANGED
@@ -156,3 +156,72 @@ test("notice counts lines of a multi-line part", () => {
156
156
 
157
157
  assert.equal(notice(parts[0]!), "Copied prose (3 lines, 31 chars)");
158
158
  });
159
+
160
+ // The answer that copied whole: a message for someone else in a blockquote, no code block.
161
+ const salomonQuote =
162
+ "Dev Fetch’s CRM now works. Please use your existing local dashboard against dev Fetch to walk a pitch deck and questionnaire through Make it, open the results, and verify Made with Prism. Also check Change before confirming. No separately hosted dashboard needed.";
163
+ const salomon = [
164
+ "Updated ASK3: no Amplify branch, just the local walkthrough against dev Fetch.",
165
+ "",
166
+ "Message for Salomon:",
167
+ "",
168
+ "> Dev Fetch’s CRM now works. Please use your existing local dashboard against dev Fetch to walk a pitch deck",
169
+ "> and questionnaire through Make it, open the results, and verify Made with Prism. Also check Change before",
170
+ "> confirming. No separately hosted dashboard needed.",
171
+ "",
172
+ "Saved and pushed as `d31765e`.",
173
+ ].join("\n");
174
+
175
+ test("a blockquote is its own part, copied as one line without the > marks", () => {
176
+ const parts = splitParts(salomon);
177
+
178
+ assert.deepEqual(
179
+ parts.map((p) => p.kind),
180
+ ["quote", "prose", "all"],
181
+ );
182
+ assert.equal(parts[0]!.label, "Quote 1: Dev Fetch’s CRM now works. Please use your existing local d…");
183
+ assert.equal(parts[0]!.text, salomonQuote);
184
+ assert.equal(notice(parts[0]!), "Copied quote (1 line, 263 chars)");
185
+ });
186
+
187
+ test("the prose part still holds the whole answer, quote included", () => {
188
+ assert.equal(
189
+ prose(salomon),
190
+ `Updated ASK3: no Amplify branch, just the local walkthrough against dev Fetch.\n\nMessage for Salomon:\n\n${salomonQuote}\n\nSaved and pushed as d31765e.`,
191
+ );
192
+ });
193
+
194
+ test("a blockquote inside a list item is a part", () => {
195
+ const answer = "1. Send Dana this:\n\n > The deck is ready for Thursday.\n\n2. Then book the room.";
196
+
197
+ const quotes = splitParts(answer).filter((p) => p.kind === "quote");
198
+
199
+ assert.deepEqual(
200
+ quotes.map((p) => p.text),
201
+ ["The deck is ready for Thursday."],
202
+ );
203
+ });
204
+
205
+ test("a blockquote inside a blockquote is one part with the outer one", () => {
206
+ const answer = "Forward this:\n\n> Hi Dana,\n>\n> > Can we move to Friday?\n>\n> Friday works.";
207
+
208
+ const quotes = splitParts(answer).filter((p) => p.kind === "quote");
209
+
210
+ assert.deepEqual(
211
+ quotes.map((p) => p.text),
212
+ ["Hi Dana,\n\nCan we move to Friday?\n\nFriday works."],
213
+ );
214
+ });
215
+
216
+ test("a code block inside a quote is copied in place and is still its own part", () => {
217
+ const answer = "Tell Sam:\n\n> Run this to log in:\n>\n> ```sh\n> aws sso login --profile fetch\n> ```\n>\n> Then retry the deploy.";
218
+
219
+ const parts = splitParts(answer);
220
+
221
+ assert.deepEqual(
222
+ parts.map((p) => p.kind),
223
+ ["code", "quote", "prose", "all"],
224
+ );
225
+ assert.equal(parts[0]!.text, "aws sso login --profile fetch");
226
+ assert.equal(parts[1]!.text, "Run this to log in:\n\naws sso login --profile fetch\n\nThen retry the deploy.");
227
+ });
package/src/parts.ts CHANGED
@@ -6,10 +6,12 @@
6
6
  // its text is marked's: fences and indent removed, no trailing newline.
7
7
  // Prose is rendered to plain text here: one line per paragraph, inline marks
8
8
  // dropped, links as `text (url)`, list markers kept, tables as source rows.
9
+ // Each blockquote is a part too (a message for someone else, usually),
10
+ // rendered like prose with its code in place.
9
11
 
10
12
  import { Lexer, type Token, type Tokens } from "marked";
11
13
 
12
- export type Part = { kind: "code" | "prose" | "all"; label: string; text: string };
14
+ export type Part = { kind: "code" | "quote" | "prose" | "all"; label: string; text: string };
13
15
 
14
16
  const PREVIEW = 60;
15
17
 
@@ -29,6 +31,16 @@ function codeBlocks(tokens: Token[], out: Tokens.Code[] = []): Tokens.Code[] {
29
31
  return out;
30
32
  }
31
33
 
34
+ // Every blockquote not inside another, in source order, wherever it sits.
35
+ function quotes(tokens: Token[], out: Tokens.Blockquote[] = []): Tokens.Blockquote[] {
36
+ for (const token of tokens) {
37
+ if (token.type === "blockquote") out.push(token as Tokens.Blockquote);
38
+ else if (token.type === "list") for (const item of (token as Tokens.List).items) quotes(item.tokens, out);
39
+ else if ("tokens" in token && Array.isArray(token.tokens)) quotes(token.tokens, out);
40
+ }
41
+ return out;
42
+ }
43
+
32
44
  // A code block's exact text, without the trailing newlines a blank line before the fence leaves.
33
45
  function codeText(code: Tokens.Code): string {
34
46
  return code.text.replace(/\n+$/, "");
@@ -132,6 +144,10 @@ export function splitParts(markdown: string): Part[] {
132
144
  label: `Code block ${index + 1}${code.lang ? ` (${code.lang})` : ""}: ${preview(code.text)}`,
133
145
  text: codeText(code),
134
146
  }));
147
+ quotes(tokens).forEach((quote, index) => {
148
+ const text = blocks(quote.tokens, true).join("\n\n");
149
+ if (text !== "") parts.push({ kind: "quote", label: `Quote ${index + 1}: ${preview(text)}`, text });
150
+ });
135
151
  const prose = blocks(tokens, false).join("\n\n");
136
152
  if (prose === "") return parts;
137
153
  parts.push({ kind: "prose", label: `Prose: ${preview(prose)}`, text: prose });
@@ -139,7 +155,7 @@ export function splitParts(markdown: string): Part[] {
139
155
  return parts;
140
156
  }
141
157
 
142
- const NOUN: Record<Part["kind"], string> = { code: "code block", prose: "prose", all: "the whole answer" };
158
+ const NOUN: Record<Part["kind"], string> = { code: "code block", quote: "quote", prose: "prose", all: "the whole answer" };
143
159
 
144
160
  export function notice(part: Part): string {
145
161
  const lines = part.text.split("\n").length;