pi-copy-picker 1.0.0 → 1.1.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.1.0 - 2026-11-10
6
+
7
+ ### Added
8
+
9
+ - Show the number of matching items while searching.
10
+
11
+ ### Changed
12
+
13
+ - Refactored message target parsing without changing selection or copy behavior.
14
+
5
15
  ## 1.0.0 - 2026-10-31
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # Pi copy picker
2
2
 
3
- ![Copy Picker gallery preview](https://raw.githubusercontent.com/tallshort/pi-copy-picker/main/gallery-preview.png)
4
-
5
3
  Browse and copy messages from the current Pi branch with `/copy-picker`.
6
4
  The fullscreen picker has a paginated message list above a Markdown preview.
7
5
  It selects the newest visible message and closes after a successful copy.
@@ -9,29 +7,15 @@ It selects the newest visible message and closes after a successful copy.
9
7
  ![Copy Picker fullscreen view with a selected code block and preview](https://raw.githubusercontent.com/tallshort/pi-copy-picker/main/copy-picker.png)
10
8
 
11
9
  Requires interactive TUI mode and `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` version 1.0.0 or newer. The host supplies these optional peer dependencies.
12
- Type checking and tests passed with 1.0.0 and 1.4.0. Other versions are accepted but have not been verified here.
10
+ Tests have passed with versions 1.0.0 and 1.4.0.
13
11
 
14
12
  ## Open the picker
15
13
 
16
- From a source checkout, load the TypeScript entry directly:
17
-
18
- ```sh
19
- pi -e ./src/index.ts
20
- ```
21
-
22
- For directory installation, run these commands from the checkout. No build is required:
23
-
24
- ```sh
25
- pi install "$PWD"
26
- pi -e .
27
- ```
28
-
29
- After the first npm publication, install the published package with:
14
+ Install the package in Pi:
30
15
 
31
16
  ```sh
32
- pi install npm:pi-copy-picker
17
+ pi install npm:pi-copy-picker
33
18
  ```
34
-
35
19
  Run `/copy-picker` in Pi. Use `/reload` or restart an existing session after installation or source changes.
36
20
  The command does not change history or tool configuration.
37
21
 
@@ -62,67 +46,18 @@ A small-window message appears below 32 columns or when the list has fewer than
62
46
 
63
47
  ## Message and clipboard behavior
64
48
 
65
- Each opening reads a saved `getBranch()` snapshot, including original text before compaction.
66
- Abandoned branches are excluded. Reopen the picker to load newer messages.
67
- Thinking, signatures, system messages, custom messages, and administrative entries are excluded. Assistant text records connected by tool-use steps form one reply, ending at stop, error, abort, a user message, or another history boundary. When tools are shown, unselected assistant text and tool events follow their original branch order.
68
-
69
- User cards use the chat theme's user background and text colors. Assistant cards have no background.
70
- The selected card has an accent border. Tool cards retain a dim border and the tool name.
71
- The upper list shows original Markdown source in conversation order. Headings, lists, tables, references, and blank source lines stay literal. Long context lines wrap at the list width.
72
- Each selected target has an accent frame with up to seven interior rows. The frame grows with its content and shrinks to fit short windows. Source lines appear in order, including code fences and punctuation. A single blank line stays blank. A run of blank lines becomes a muted `...`. If the source or its wrapped lines exceed the available rows, the last row is a muted `...`. Literal `...` remains ordinary source text. Preview and copy retain the complete selected entity. When tools are shown, a selected grouped reply frames its first segment and adds a muted `...` inside the frame if later segments contain text. Later segments remain unframed after their tool events. Preview and copy cover the whole reply. Selecting a block replaces only its original source interval. If a block spans tool events, its frame appears at the block's start and may summarize text that originally followed those events. The tool events remain in their original order, and text after the block remains visible. Changing selection centers the new frame, clamped at history ends. Reserved `...` rows indicate additional list content above and below. The bottom Pi-rendered preview shows only the selected entity.
73
- A local source scanner finds fenced and indented code and nonempty blockquotes. Pi's Markdown tokenizer identifies standalone inline-code lines. These are the only selectable children. A standalone inline-code line contains one nonempty span and copies its content without backticks. Inline code within a sentence does not create a separate block. Lists, headings, tables, and prose remain in the whole-message preview and copy, but do not create children.
74
- Search matches whole messages and tool names, so matching messages retain all their children.
75
- Search filters as you type. Every effective query edit clears selection and preview. Clearing the query immediately restores all messages. Down selects the first message on the visible page and Up selects the last, without needing Enter first. Repeated Enter presses do nothing until you select a message.
76
- While selection is inactive, the preview shows the dim, centered prompt `Use ↑/↓ to select a message`. Paging, tool visibility, preview scrolling, and resizing do not activate selection.
77
- Block frames leave two columns on both sides. Parent frames use the full width. Block frames have no type labels. Code excerpts show the original fence lines, language info and quote/list prefixes when they fit. Unclosed fences get no invented closing marker. Indented code keeps its source indentation without added fences. Code previews show only the highlighted body, and code copy still excludes fences and container prefixes.
78
-
79
- Source display lines and preview Markdown rendering are cached per message or block until width or theme changes. Many long messages can increase memory use until the picker closes.
80
-
81
- Whole-message copy joins original text blocks within a record with newlines and preserves surrounding whitespace. For grouped assistant records it adds enough newline characters at each seam to make a paragraph separator of at least two line breaks, preserving existing line breaks. A single record stays unchanged.
82
- Blockquote children copy plain text with one outer `>` prefix and at most one following space or tab removed per line. Quoted blank paragraphs stay in one target. Nested quote prefixes, internal whitespace, and line endings are preserved. Lazy continuation lines stay unchanged until an unquoted blank paragraph. Empty quotes have no child. Quote previews still render the original Markdown. Code children copy only code, without fences or Markdown quote/list prefixes. Copy removes trailing newline characters from code and quote blocks while preserving indentation, spaces, and internal line endings.
83
- Quote fences stop when their quote container ends. List code indentation is measured four spaces beyond the list content indentation. A dedented nonblank line ends that list context.
84
- The scanner covers space-indented lists and these common block forms rather than the full CommonMark grammar. Tab-expanded list indentation and lazy container continuation are not parsed as full CommonMark containers. All source remains available in the whole-message copy. Inline-code children require an unambiguous top-level paragraph token. Some valid spans in nested or otherwise ambiguous Markdown are skipped. Pi still renders and syntax-highlights the previews.
85
- Images are not copied. Empty messages and image-only messages show `No copyable text`.
86
- Tool calls copy formatted argument JSON, not the original request bytes.
87
- Tool results copy text blocks without their `details` field.
88
- Only displayed text has terminal control sequences removed. Do not paste untrusted copied text directly into a shell.
89
- Generated UI text is English. Original messages keep their language.
90
-
91
- Copy uses Pi's clipboard helper. Remote sessions may depend on OSC52 support in the terminal and multiplexer.
92
- Pi 1.4.0 limits OSC52 to 100000 Base64 characters. Large copies can fail without a local clipboard backend.
93
- Repeated Enter presses do not submit duplicate copies while a copy is pending. A failure leaves the picker open for retry.
49
+ The picker reads the current branch when it opens. Reopen it to see newer messages. It excludes abandoned branches, thinking, and images.
94
50
 
95
- In fullscreen Pi, success uses the native transient `flash("Copied!")`.
96
- In regular Pi, success uses `ctx.ui.notify` with `Copied message to clipboard`, without a dismissal timer.
97
- Success means the helper returned, not that the terminal confirmed clipboard contents.
51
+ Select a whole message, a code block, a standalone inline-code line, or a blockquote. The list shows Markdown source; the preview renders the selected content. Long selections are shortened in the list, but preview and copy include the complete selection.
98
52
 
99
- ## Develop in a local sandbox
53
+ Code copies without fences or Markdown container prefixes. Standalone inline code copies without backticks. Blockquotes copy without the outer quote marker. Images are not copied. Tool calls copy formatted arguments, and tool results copy text.
100
54
 
101
- On Linux with a shared virtiofs checkout, keep `node_modules` outside the checkout.
102
- Copy the source to a sandbox-local directory. Repeat the copy when source or metadata changes.
103
- Run the following from the checkout, using a fresh or dedicated `/tmp/pi-copy-picker-dev` directory:
55
+ Search matches messages and tool names and shows the number of matching items. Changing the query clears the selection; use Up or Down to select a result before copying.
104
56
 
105
- ```sh
106
- mkdir -p /tmp/pi-copy-picker-dev
107
- tar --exclude='./node_modules' --exclude='./.git' --exclude='*.tgz' -cf - . | tar -xf - -C /tmp/pi-copy-picker-dev
108
- cd /tmp/pi-copy-picker-dev
109
- npm ci
110
- npm run check
111
- npm pack
112
- ```
57
+ Copy uses Pi's clipboard helper. Remote sessions may require OSC 52 support in the terminal and multiplexer. Large copies may fail when no local clipboard is available. If copying fails, the picker stays open for retry.
113
58
 
114
- Stop if dependency installation fails. Run checks only after `npm ci` succeeds.
115
- `check` runs type checking, runtime tests, release guard tests, and a real tarball contract check.
116
- `prepack` runs the same checks. The package checker uses `--ignore-scripts` to avoid recursive packing.
117
- The package ships the three TypeScript source modules. Pi loads them with its TypeScript loader.
118
- The package checker extracts the tarball and uses Pi's loader to verify command registration.
119
- The entry is for Pi, not a promise that plain Node can execute the TypeScript source.
120
-
121
- `src/history.ts` converts history. `src/picker.ts` owns selection, search, scrolling, and copy state.
122
- `src/index.ts` registers the command. Tests use the real Input, Markdown renderer, and key parser with injected clipboard and dimensions.
123
- IME positioning and actual clipboard reception still need checks in your target terminal.
59
+ Only displayed text has terminal control sequences removed. Do not paste untrusted copied text directly into a shell.
124
60
 
125
- For publication prerequisites and tag commands, see [Release the package](RELEASING.md).
126
- The [MIT license](LICENSE) and [changelog](CHANGELOG.md) ship with the package.
61
+ ## Development
127
62
 
128
- Maintained by tallshort. Repository: [tallshort/pi-copy-picker](https://github.com/tallshort/pi-copy-picker).
63
+ See [Development](https://github.com/tallshort/pi-copy-picker/blob/main/DEVELOPMENT.md) for local setup and implementation details. See [Release the package](RELEASING.md) for publication instructions.
package/RELEASING.md CHANGED
@@ -6,14 +6,14 @@
6
6
  2. Set `origin` fetch and push URLs to that repository. If the repository changes, update `repository.url`, `homepage`, and `bugs.url` in `package.json` before publishing.
7
7
  3. Commit the package, lockfile, docs, and workflow on `main`. Push `main`. Keep the working tree clean. Do not commit `node_modules/`. The package publishes `src/` directly.
8
8
  4. Confirm that your npm account can publish the top-level package `pi-copy-picker`. The package name is not scoped; `tallshort` is the author. Use Node 24 and npm 11.5.1 or newer. Trusted publishing requires at least Node 22.14.0 and npm 11.5.1.
9
- 5. Follow the README's isolated install and check procedure. From the checked sandbox copy, use an interactive npm login and publish the first version manually:
9
+ 5. Follow the [development setup](https://github.com/tallshort/pi-copy-picker/blob/main/DEVELOPMENT.md) and checks. From the checked copy, use an interactive npm login and publish the first version manually:
10
10
 
11
11
  ```sh
12
12
  npm login
13
13
  npm publish --access public
14
14
  ```
15
15
 
16
- The human runs these commands. No publication has been performed by this preparation task.
16
+ The human runs these commands.
17
17
  A package must exist before its trusted publisher can be configured. Do not expect OIDC to bootstrap an unpublished package.
18
18
  The first manual publication does not claim GitHub Actions provenance.
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-copy-picker",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Fullscreen current-branch message copy picker for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/history.ts CHANGED
@@ -85,51 +85,28 @@ export type PickTarget = Readonly<{
85
85
  rawText: string;
86
86
  } & ({ kind: "message" } | { kind: "block"; blockKind: "quote" | "code" | "inline-code"; language?: string; sourceText: string; sourceStart: number; sourceEnd: number; fence?: { opening: string; closing?: string } })>;
87
87
 
88
- // Source lines keep clipboard payloads independent of rendering.
89
- export function toPickTargets(message: PickItem): readonly PickTarget[] {
90
- const parent: PickTarget = { kind: "message", id: message.id, messageId: message.id, message, rawText: message.rawText };
91
- if (message.kind !== "user" && message.kind !== "assistant") return [parent];
92
- if (message.segments && message.segments.length > 1) {
93
- const sourceLines = message.rawText.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? [];
94
- const spans: { sourceStart: number; sourceEnd: number; entryId: string }[] = [];
95
- for (const segment of message.segments) {
96
- const last = spans.at(-1);
97
- if (last?.entryId === segment.entryId) last.sourceEnd = segment.sourceEnd;
98
- else spans.push({ ...segment });
99
- }
100
- const blocks = spans.flatMap(segment => {
101
- const start = segment.sourceStart + (message.rawText[segment.sourceStart - 1] === "\r" && message.rawText[segment.sourceStart] === "\n" ? 1 : 0);
102
- const offset = (message.rawText.slice(0, start).match(/\r\n|\r|\n/g) ?? []).length;
103
- return toPickTargets({ ...message, segments: undefined, rawText: message.rawText.slice(start, segment.sourceEnd) })
104
- .filter((target): target is Extract<PickTarget, { kind: "block" }> => target.kind === "block")
105
- .map(target => ({ ...target, message, sourceStart: target.sourceStart + offset, sourceEnd: target.sourceEnd + offset,
106
- id: `${message.id}:block:${target.sourceStart + offset}`,
107
- sourceText: sourceLines.slice(target.sourceStart + offset, target.sourceEnd + offset).join("") }));
108
- });
109
- return [parent, ...blocks];
88
+ function segmentBlocks(message: PickItem, segments: NonNullable<PickItem["segments"]>): PickTarget[] {
89
+ const sourceLines = message.rawText.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? [];
90
+ const spans: { sourceStart: number; sourceEnd: number; entryId: string }[] = [];
91
+ for (const segment of segments) {
92
+ const last = spans.at(-1);
93
+ if (last?.entryId === segment.entryId) last.sourceEnd = segment.sourceEnd;
94
+ else spans.push({ ...segment });
110
95
  }
111
- const lines = message.rawText.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? [];
112
- const blocks: PickTarget[] = [];
113
- let rich = false;
96
+ const blocks = spans.flatMap(segment => {
97
+ const start = segment.sourceStart + (message.rawText[segment.sourceStart - 1] === "\r" && message.rawText[segment.sourceStart] === "\n" ? 1 : 0);
98
+ const offset = (message.rawText.slice(0, start).match(/\r\n|\r|\n/g) ?? []).length;
99
+ return toPickTargets({ ...message, segments: undefined, rawText: message.rawText.slice(start, segment.sourceEnd) })
100
+ .filter((target): target is Extract<PickTarget, { kind: "block" }> => target.kind === "block")
101
+ .map(target => ({ ...target, message, sourceStart: target.sourceStart + offset, sourceEnd: target.sourceEnd + offset,
102
+ id: `${message.id}:block:${target.sourceStart + offset}`,
103
+ sourceText: sourceLines.slice(target.sourceStart + offset, target.sourceEnd + offset).join("") }));
104
+ });
105
+ return blocks;
106
+ }
107
+
108
+ function analyzeMarkdownLines(rawText: string, lines: string[]) {
114
109
  const content = (line: string) => line.replace(/(?:\r\n|\r|\n)$/, "");
115
- const unquote = (line: string) => line.replace(/^(?: {0,3}>[ \t]?)+/, "");
116
- const listIndents: number[] = [];
117
- const listContext = (line: string) => {
118
- const text = unquote(content(line));
119
- if (!text.trim()) return;
120
- const indent = /^ */.exec(text)![0].length;
121
- while (listIndents.length && indent < listIndents.at(-1)!) listIndents.pop();
122
- const marker = /^( *)(?:[-+*]|\d+[.)])([ \t]+)/.exec(text);
123
- if (marker && indent <= (listIndents.at(-1) ?? 0) + 3) {
124
- const contentIndent = marker[0].length;
125
- if (listIndents.at(-1) !== contentIndent) listIndents.push(contentIndent);
126
- }
127
- };
128
- const codeIndent = () => (listIndents.at(-1) ?? 0) + 4;
129
- const fence = (line: string) => {
130
- const match = /^( *)(?:([-+*]|\d+[.)])[ \t]+)?(`{3,}|~{3,})([^\r\n]*)$/.exec(unquote(content(line)));
131
- return match && (match[1]!.length <= (listIndents.at(-1) ?? 0) + 3) ? match : null;
132
- };
133
110
  const inlineCode = (line: string): string | undefined => {
134
111
  const text = content(line);
135
112
  const opener = /^ {0,3}(`+)(?!`)/.exec(text);
@@ -143,7 +120,6 @@ export function toPickTargets(message: PickItem): readonly PickTarget[] {
143
120
  }
144
121
  return undefined;
145
122
  };
146
- const structure = (line: string) => /^(?: {0,3}(?:#{1,6}(?:\s|$)|>|[-+*]\s|\d+[.)]\s)| {4}|\t)|^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/.test(content(line));
147
123
  const inlineCandidates = new Map<number, { line: number; raw: string; payload: string }>();
148
124
  let sourceOffset = 0;
149
125
  for (let line = 0; line < lines.length; line++) {
@@ -156,7 +132,7 @@ export function toPickTargets(message: PickItem): readonly PickTarget[] {
156
132
  sourceOffset += lines[line]!.replace(/\r\n|\r/g, "\n").length;
157
133
  }
158
134
  const inlineTargets = new Map<number, string>();
159
- const normalized = message.rawText.replace(/\r\n|\r/g, "\n");
135
+ const normalized = rawText.replace(/\r\n|\r/g, "\n");
160
136
  const protectedLines = new Set<number>();
161
137
  {
162
138
  let offset = 0;
@@ -184,6 +160,38 @@ export function toPickTargets(message: PickItem): readonly PickTarget[] {
184
160
  offset += token.raw.length;
185
161
  }
186
162
  }
163
+ return { inlineTargets, protectedLines };
164
+ }
165
+
166
+ // Source lines keep clipboard payloads independent of rendering.
167
+ export function toPickTargets(message: PickItem): readonly PickTarget[] {
168
+ const parent: PickTarget = { kind: "message", id: message.id, messageId: message.id, message, rawText: message.rawText };
169
+ if (message.kind !== "user" && message.kind !== "assistant") return [parent];
170
+ if (message.segments && message.segments.length > 1) return [parent, ...segmentBlocks(message, message.segments)];
171
+ const lines = message.rawText.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? [];
172
+ const blocks: PickTarget[] = [];
173
+ let rich = false;
174
+ const content = (line: string) => line.replace(/(?:\r\n|\r|\n)$/, "");
175
+ const unquote = (line: string) => line.replace(/^(?: {0,3}>[ \t]?)+/, "");
176
+ const listIndents: number[] = [];
177
+ const listContext = (line: string) => {
178
+ const text = unquote(content(line));
179
+ if (!text.trim()) return;
180
+ const indent = /^ */.exec(text)![0].length;
181
+ while (listIndents.length && indent < listIndents.at(-1)!) listIndents.pop();
182
+ const marker = /^( *)(?:[-+*]|\d+[.)])([ \t]+)/.exec(text);
183
+ if (marker && indent <= (listIndents.at(-1) ?? 0) + 3) {
184
+ const contentIndent = marker[0].length;
185
+ if (listIndents.at(-1) !== contentIndent) listIndents.push(contentIndent);
186
+ }
187
+ };
188
+ const codeIndent = () => (listIndents.at(-1) ?? 0) + 4;
189
+ const fence = (line: string) => {
190
+ const match = /^( *)(?:([-+*]|\d+[.)])[ \t]+)?(`{3,}|~{3,})([^\r\n]*)$/.exec(unquote(content(line)));
191
+ return match && (match[1]!.length <= (listIndents.at(-1) ?? 0) + 3) ? match : null;
192
+ };
193
+ const structure = (line: string) => /^(?: {0,3}(?:#{1,6}(?:\s|$)|>|[-+*]\s|\d+[.)]\s)| {4}|\t)|^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/.test(content(line));
194
+ const { inlineTargets, protectedLines } = analyzeMarkdownLines(message.rawText, lines);
187
195
  const inlineAt = (index: number) => inlineTargets.get(index);
188
196
  const add = (start: number, end: number, blockKind: "quote" | "code" | "inline-code", rawText = lines.slice(start, end).join(""), language?: string, fence?: { opening: string; closing?: string }) => {
189
197
  const sourceText = lines.slice(start, end).join("");
package/src/picker.ts CHANGED
@@ -513,9 +513,12 @@ export class PickerScreen implements Component, Focusable {
513
513
  : Math.min(pageCount - 1, this.pageIndex ?? Math.ceil(this.viewportStart / this.listRows()));
514
514
  const help = "Enter copy · ←/→ prev/next page · Ctrl+T tools · PgUp/PgDn scroll preview · Esc close";
515
515
  const status = this.status();
516
+ const matches = this.visibleItems().length;
517
+ const countText = this.queryInput.getValue() ? `${matches} ${matches === 1 ? "match" : "matches"}` : "";
518
+ const matchCount = countText && width >= visibleWidth("type to search > ") + visibleWidth(this.queryInput.getValue()) + visibleWidth(countText) + 2 ? " " + this.theme.fg("dim", countText) : "";
516
519
  const prefix = `Page ${pageIndex + 1}/${pageCount} ${this.theme.fg("dim", `(tool messages ${this.showTools ? "included" : "excluded"})`)}, `;
517
- const inputWidth = Math.min(width, visibleWidth("type to search > ") + visibleWidth(this.queryInput.getValue()) + 1);
518
- const shownPrefix = truncateToWidth(prefix, Math.max(0, width - inputWidth), "");
520
+ const inputWidth = Math.min(width - visibleWidth(matchCount), visibleWidth("type to search > ") + visibleWidth(this.queryInput.getValue()) + 1);
521
+ const shownPrefix = truncateToWidth(prefix, Math.max(0, width - visibleWidth(matchCount) - inputWidth), "");
519
522
  const arrow = (show: boolean, shaded: boolean) => {
520
523
  if (!show) return "";
521
524
  const text = " " + this.theme.fg("muted", "...");
@@ -530,7 +533,7 @@ export class PickerScreen implements Component, Focusable {
530
533
  while (padding.has(visibleEnd - 1) && visibleEnd > visibleStart) visibleEnd--;
531
534
  const lines = [this.theme.fg("dim", help),
532
535
  status ? this.theme.fg(this.copyState.kind === "error" ? "error" : "muted", status)
533
- : shownPrefix + this.queryInput.render(Math.max(1, width - visibleWidth(shownPrefix)))[0],
536
+ : shownPrefix + this.queryInput.render(Math.max(1, width - visibleWidth(shownPrefix) - visibleWidth(matchCount)))[0] + matchCount,
534
537
  arrow(stream.content.slice(0, visibleStart).some(Boolean), !!stream.userBackground[visibleStart - 1] && !!stream.userBackground[visibleStart])];
535
538
  const cardRows = stream.lines.slice(visibleStart, visibleEnd);
536
539
  if (!stream.lines.length) cardRows.push(" No matching messages");