pi-copy-picker 0.0.0-stage → 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 +22 -0
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/RELEASING.md +68 -0
- package/gallery-preview.png +0 -0
- package/package.json +64 -4
- package/src/history.ts +285 -0
- package/src/index.ts +64 -0
- package/src/picker.ts +580 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
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
|
+
|
|
15
|
+
## 1.0.0 - 2026-10-31
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Fullscreen current-branch message picker with search, source context, Markdown preview, and clipboard copy.
|
|
20
|
+
- Block selection for code, quotes, and standalone inline code, plus optional tool calls and outputs.
|
|
21
|
+
- `/copy-block` and `Ctrl+Shift+B` to copy the latest assistant block or message.
|
|
22
|
+
- Direct TypeScript package, package contract checks, and guarded tag-based publishing.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tallshort
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi copy picker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Browse and copy messages from the current Pi branch with `/copy-picker`.
|
|
4
|
+
The fullscreen picker has a paginated message list above a Markdown preview.
|
|
5
|
+
It selects the newest visible message and closes after a successful copy.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
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.
|
|
10
|
+
Tests have passed with versions 1.0.0 and 1.4.0.
|
|
11
|
+
|
|
12
|
+
## Open the picker
|
|
13
|
+
|
|
14
|
+
Install the package in Pi:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pi install npm:pi-copy-picker
|
|
18
|
+
```
|
|
19
|
+
Run `/copy-picker` in Pi. Use `/reload` or restart an existing session after installation or source changes.
|
|
20
|
+
The command does not change history or tool configuration.
|
|
21
|
+
|
|
22
|
+
## Copy the last block
|
|
23
|
+
|
|
24
|
+
Run `/copy-block` or press Ctrl+Shift+B in the main editor to copy the last selectable block in the latest grouped assistant reply on the current branch. If that message has no selectable block, it copies the whole message instead. It uses the same block and clipboard rules as the picker, ignores thinking and tool messages, and skips aborted empty assistant messages. It does not fall back to an older message when the latest message has no copyable text.
|
|
25
|
+
|
|
26
|
+
Ctrl+Shift+B requires a terminal that distinguishes it from Ctrl+B. The terminal, operating system, or another binding may intercept it. Use `/copy-block` if the shortcut does not reach Pi. Both require TUI mode and leave the editor and conversation unchanged.
|
|
27
|
+
|
|
28
|
+
## Controls
|
|
29
|
+
|
|
30
|
+
| Key | Action |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| Up / Down | Select the whole message, then its code, inline-code, and blockquote children in source order, then the next message. Up reverses this order. No wrapping |
|
|
33
|
+
| Left / Right | Change page. Select its first message only when a selection is already active |
|
|
34
|
+
| Type | Edit the search, ignoring case, and clear selection and preview immediately |
|
|
35
|
+
| Ctrl+A / Ctrl+E | Move the search cursor to the start or end |
|
|
36
|
+
| Ctrl+U / Ctrl+K | Delete search text before or after the cursor |
|
|
37
|
+
| Ctrl+T | Show or hide tool calls and results, hidden by default |
|
|
38
|
+
| Enter | Copy an active selection and close on success. Do nothing when no message is selected |
|
|
39
|
+
| PgUp / PgDown | Scroll the preview by one page |
|
|
40
|
+
| Shift+Up / Shift+Down | Scroll the preview by one line |
|
|
41
|
+
| Ctrl+Home / Ctrl+End | Jump to the start or end of the preview |
|
|
42
|
+
| Esc | Close, even during a pending copy or an active search |
|
|
43
|
+
|
|
44
|
+
Pi's configured selection, confirm, and cancel keys also work. Fixed keys take priority.
|
|
45
|
+
A small-window message appears below 32 columns or when the list has fewer than three rows for the selected frame. Search and Escape still work; copying waits until the frame fits.
|
|
46
|
+
|
|
47
|
+
## Message and clipboard behavior
|
|
48
|
+
|
|
49
|
+
The picker reads the current branch when it opens. Reopen it to see newer messages. It excludes abandoned branches, thinking, and images.
|
|
50
|
+
|
|
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.
|
|
52
|
+
|
|
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.
|
|
54
|
+
|
|
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.
|
|
56
|
+
|
|
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.
|
|
58
|
+
|
|
59
|
+
Only displayed text has terminal control sequences removed. Do not paste untrusted copied text directly into a shell.
|
|
60
|
+
|
|
61
|
+
## Development
|
|
62
|
+
|
|
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
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Release the package
|
|
2
|
+
|
|
3
|
+
## Prepare the first publication
|
|
4
|
+
|
|
5
|
+
1. Create or confirm the public repository `https://github.com/tallshort/pi-copy-picker`. The package metadata already targets it.
|
|
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
|
+
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
|
+
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 [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
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm login
|
|
13
|
+
npm publish --access public
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The human runs these commands.
|
|
17
|
+
A package must exist before its trusted publisher can be configured. Do not expect OIDC to bootstrap an unpublished package.
|
|
18
|
+
The first manual publication does not claim GitHub Actions provenance.
|
|
19
|
+
|
|
20
|
+
## Configure trusted publishing
|
|
21
|
+
|
|
22
|
+
After the npm package exists, open its npm settings and add a GitHub Actions trusted publisher.
|
|
23
|
+
Use GitHub owner `tallshort`, repository `pi-copy-picker`, and the exact workflow filename `release.yml`.
|
|
24
|
+
Leave the environment unset unless you also add that environment to the workflow job.
|
|
25
|
+
Use [npm's trusted publisher guide](https://docs.npmjs.com/trusted-publishers/) and the [npm trust reference](https://docs.npmjs.com/cli/v11/commands/npm-trust/).
|
|
26
|
+
|
|
27
|
+
The workflow uses a GitHub-hosted runner, Node 24, `id-token: write`, and `npm publish --access public --provenance`.
|
|
28
|
+
Provenance requires a public repository and matching `repository.url` metadata.
|
|
29
|
+
Do not add `NPM_TOKEN` or other long-lived publication credentials to this workflow.
|
|
30
|
+
The workflow rejects prereleases, mismatched package versions, mismatched repository identity, tags outside `main`, and incompatible npm versions.
|
|
31
|
+
|
|
32
|
+
## Prepare future tags
|
|
33
|
+
|
|
34
|
+
Use an ordinary local clone with dependencies installed, not the shared checkout.
|
|
35
|
+
The release helper requires a real Git root, clean `main`, matching origin and repository metadata, and HEAD equal to remote `main`.
|
|
36
|
+
It checks local and remote tag collisions and runs the complete check before any version or tag mutation.
|
|
37
|
+
The default only performs preflight checks. It does not fetch, pull, publish, push, or change versions or refs.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npm run release -- current
|
|
41
|
+
npm run release -- patch
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`current` targets the existing package version. It can create the initial `v1.0.0` tag without bumping to `1.0.1`.
|
|
45
|
+
After manually publishing 1.0.0, do not push its tag into the publish workflow, because npm rejects duplicate versions.
|
|
46
|
+
Use a patch, minor, or major release for the next CI publication.
|
|
47
|
+
Update the changelog and commit it before running the helper.
|
|
48
|
+
|
|
49
|
+
To create local release refs after reviewing the preflight result:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm run release -- patch --execute
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--execute` updates the manifest and lockfile, reruns checks, commits the version change, and creates an annotated tag.
|
|
56
|
+
For `current`, it only creates the tag. The helper never pushes.
|
|
57
|
+
If a check or commit fails after the version change, inspect and resolve the local edits before retrying. There is no automatic rollback.
|
|
58
|
+
|
|
59
|
+
Review the generated commit and tag, then atomically push `main` and the exact tag. Replace the example tag with the version you prepared:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
git push --atomic origin main refs/tags/v1.0.1
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A tag push triggers CI. It does not prove publication succeeded.
|
|
66
|
+
Check the workflow result and the registry version before announcing a release.
|
|
67
|
+
If CI fails before publication, fix the cause and inspect registry state before deciding whether to retry.
|
|
68
|
+
Never overwrite a published version or force-push a release tag.
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-copy-picker",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Fullscreen current-branch message copy picker for Pi",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"pi": {
|
|
10
|
+
"extensions": [
|
|
11
|
+
"./src/index.ts"
|
|
12
|
+
],
|
|
13
|
+
"image": "https://raw.githubusercontent.com/tallshort/pi-copy-picker/main/gallery-preview.png"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "tsx --test tests/*.test.ts",
|
|
18
|
+
"test:release": "node --test scripts/release.test.mjs",
|
|
19
|
+
"check:package": "node scripts/check-package.mjs",
|
|
20
|
+
"check": "npm run typecheck && npm test && npm run test:release && npm run check:package",
|
|
21
|
+
"prepack": "npm run check",
|
|
22
|
+
"release": "node scripts/release.mjs"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0",
|
|
26
|
+
"@earendil-works/pi-tui": ">=1.0.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": {
|
|
30
|
+
"optional": true
|
|
31
|
+
},
|
|
32
|
+
"@earendil-works/pi-tui": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
38
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
39
|
+
"@types/node": "^22.0.0",
|
|
40
|
+
"tsx": "^4.20.0",
|
|
41
|
+
"typescript": "^5.9.0"
|
|
42
|
+
},
|
|
43
|
+
"author": "tallshort",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/tallshort/pi-copy-picker.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/tallshort/pi-copy-picker#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/tallshort/pi-copy-picker/issues"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"main": "./src/index.ts",
|
|
57
|
+
"exports": "./src/index.ts",
|
|
58
|
+
"files": [
|
|
59
|
+
"src/",
|
|
60
|
+
"gallery-preview.png",
|
|
61
|
+
"README.md",
|
|
62
|
+
"LICENSE",
|
|
63
|
+
"CHANGELOG.md",
|
|
64
|
+
"RELEASING.md"
|
|
65
|
+
]
|
|
66
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { Marked } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
type ItemKind =
|
|
5
|
+
| { readonly kind: "user" | "assistant" }
|
|
6
|
+
| { readonly kind: "tool-call" | "tool-result"; readonly toolName: string };
|
|
7
|
+
|
|
8
|
+
export type PickItem = Readonly<ItemKind & {
|
|
9
|
+
id: string;
|
|
10
|
+
order?: number;
|
|
11
|
+
segments?: readonly { sourceStart: number; sourceEnd: number; entryId: string; order: number }[];
|
|
12
|
+
omittedNonText?: boolean;
|
|
13
|
+
rawText: string;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
export function toPickerItems(entries: readonly SessionEntry[]): readonly PickItem[] {
|
|
17
|
+
const items: PickItem[] = [];
|
|
18
|
+
let group: { index: number; id: string; rawText: string; omitted: boolean; segments: { sourceStart: number; sourceEnd: number; entryId: string; order: number }[] } | undefined;
|
|
19
|
+
const flush = () => {
|
|
20
|
+
if (!group) return;
|
|
21
|
+
items[group.index] = Object.freeze({ id: group.id, kind: "assistant", rawText: group.rawText, omittedNonText: group.omitted, segments: group.segments, order: group.segments[0]?.order });
|
|
22
|
+
group = undefined;
|
|
23
|
+
};
|
|
24
|
+
let order = 0;
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry.type !== "message") { flush(); continue; }
|
|
27
|
+
const message = entry.message;
|
|
28
|
+
if (message.role !== "user" && message.role !== "assistant" && message.role !== "toolResult") { flush(); continue; }
|
|
29
|
+
const content = message.content;
|
|
30
|
+
const rawText = typeof content === "string"
|
|
31
|
+
? content
|
|
32
|
+
: content.filter(block => block.type === "text").map(block => block.text).join("\n");
|
|
33
|
+
const omitted = typeof content !== "string" && content.some(block => block.type === "image" || block.type === "toolCall" || block.type === "thinking");
|
|
34
|
+
if (message.role === "toolResult") {
|
|
35
|
+
items.push(Object.freeze({ id: `${entry.id}:result`, kind: "tool-result", toolName: message.toolName, order: order++, omittedNonText: omitted, rawText }));
|
|
36
|
+
} else if (message.role === "user") {
|
|
37
|
+
flush();
|
|
38
|
+
items.push(Object.freeze({ id: `${entry.id}:message`, kind: "user", order: order++, omittedNonText: omitted, rawText }));
|
|
39
|
+
} else {
|
|
40
|
+
const hasText = message.content.some(block => block.type === "text");
|
|
41
|
+
const empty = message.content.length === 0 && message.stopReason !== "aborted";
|
|
42
|
+
const include = (hasText || empty) && !(message.stopReason === "aborted" && !rawText.length);
|
|
43
|
+
let segment: { sourceStart: number; sourceEnd: number; entryId: string; order: number } | undefined;
|
|
44
|
+
let seenText = false;
|
|
45
|
+
const append = (text: string) => {
|
|
46
|
+
if (!group) {
|
|
47
|
+
group = { index: items.length, id: `${entry.id}:message`, rawText: "", omitted, segments: [] };
|
|
48
|
+
items.push(Object.freeze({ id: group.id, kind: "assistant", rawText: "", omittedNonText: omitted }));
|
|
49
|
+
}
|
|
50
|
+
if (!segment) {
|
|
51
|
+
segment = { sourceStart: group.rawText.length, sourceEnd: 0, entryId: entry.id, order: order++ };
|
|
52
|
+
group.segments.push(segment);
|
|
53
|
+
}
|
|
54
|
+
if (!seenText && group.rawText && rawText) {
|
|
55
|
+
const trailing = (group.rawText.match(/(?:\r\n|\r|\n)+$/)?.[0].match(/\r\n|\r|\n/g) ?? []).length;
|
|
56
|
+
const leading = (rawText.match(/^(?:\r\n|\r|\n)+/)?.[0].match(/\r\n|\r|\n/g) ?? []).length;
|
|
57
|
+
const seam = trailing + leading - (group.rawText.endsWith("\r") && rawText.startsWith("\n") ? 1 : 0);
|
|
58
|
+
group.rawText += "\n".repeat(Math.max(0, 2 - seam));
|
|
59
|
+
}
|
|
60
|
+
group.rawText += (seenText ? "\n" : "") + text;
|
|
61
|
+
segment.sourceEnd = group.rawText.length;
|
|
62
|
+
group.omitted ||= omitted;
|
|
63
|
+
seenText = true;
|
|
64
|
+
};
|
|
65
|
+
if (include && empty) append("");
|
|
66
|
+
message.content.forEach((block, index) => {
|
|
67
|
+
if (block.type === "text" && include) append(block.text);
|
|
68
|
+
else if (block.type === "toolCall") {
|
|
69
|
+
segment = undefined;
|
|
70
|
+
items.push(Object.freeze({ id: `${entry.id}:call:${index}`, kind: "tool-call", toolName: block.name, order: order++,
|
|
71
|
+
rawText: JSON.stringify(block.arguments, null, 2) }));
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
if (message.stopReason !== "toolUse") flush();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
flush();
|
|
78
|
+
return Object.freeze(items);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type PickTarget = Readonly<{
|
|
82
|
+
id: string;
|
|
83
|
+
messageId: string;
|
|
84
|
+
message: PickItem;
|
|
85
|
+
rawText: string;
|
|
86
|
+
} & ({ kind: "message" } | { kind: "block"; blockKind: "quote" | "code" | "inline-code"; language?: string; sourceText: string; sourceStart: number; sourceEnd: number; fence?: { opening: string; closing?: string } })>;
|
|
87
|
+
|
|
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 });
|
|
95
|
+
}
|
|
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[]) {
|
|
109
|
+
const content = (line: string) => line.replace(/(?:\r\n|\r|\n)$/, "");
|
|
110
|
+
const inlineCode = (line: string): string | undefined => {
|
|
111
|
+
const text = content(line);
|
|
112
|
+
const opener = /^ {0,3}(`+)(?!`)/.exec(text);
|
|
113
|
+
if (!opener) return undefined;
|
|
114
|
+
const runs = /`+/g;
|
|
115
|
+
runs.lastIndex = opener[0].length;
|
|
116
|
+
for (let match = runs.exec(text); match; match = runs.exec(text)) {
|
|
117
|
+
if (match[0].length !== opener[1]!.length) continue;
|
|
118
|
+
const payload = text.slice(opener[0].length, match.index);
|
|
119
|
+
return /^[ \t]*$/.test(text.slice(match.index + match[0].length)) && payload.trim() ? payload : undefined;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
};
|
|
123
|
+
const inlineCandidates = new Map<number, { line: number; raw: string; payload: string }>();
|
|
124
|
+
let sourceOffset = 0;
|
|
125
|
+
for (let line = 0; line < lines.length; line++) {
|
|
126
|
+
const text = content(lines[line]!);
|
|
127
|
+
const payload = inlineCode(lines[line]!);
|
|
128
|
+
if (payload !== undefined) {
|
|
129
|
+
const indent = /^ */.exec(text)![0].length;
|
|
130
|
+
inlineCandidates.set(sourceOffset + indent, { line, raw: text.slice(indent).trimEnd(), payload });
|
|
131
|
+
}
|
|
132
|
+
sourceOffset += lines[line]!.replace(/\r\n|\r/g, "\n").length;
|
|
133
|
+
}
|
|
134
|
+
const inlineTargets = new Map<number, string>();
|
|
135
|
+
const normalized = rawText.replace(/\r\n|\r/g, "\n");
|
|
136
|
+
const protectedLines = new Set<number>();
|
|
137
|
+
{
|
|
138
|
+
let offset = 0;
|
|
139
|
+
let line = 0;
|
|
140
|
+
for (const token of new Marked().lexer(normalized)) {
|
|
141
|
+
if (!normalized.startsWith(token.raw, offset)) break;
|
|
142
|
+
const count = (token.raw.match(/\n/g) ?? []).length;
|
|
143
|
+
if (token.type === "paragraph" || token.type === "html") {
|
|
144
|
+
for (let n = 1; n <= count; n++) {
|
|
145
|
+
if (n < count || !token.raw.endsWith("\n")) protectedLines.add(line + n);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
line += count;
|
|
149
|
+
if (token.type === "paragraph") {
|
|
150
|
+
let innerOffset = offset;
|
|
151
|
+
for (const inline of token.tokens ?? []) {
|
|
152
|
+
if (!normalized.startsWith(inline.raw, innerOffset)) break;
|
|
153
|
+
const candidate = inlineCandidates.get(innerOffset);
|
|
154
|
+
if (inline.type === "codespan" && candidate?.raw === inline.raw) {
|
|
155
|
+
inlineTargets.set(candidate.line, candidate.payload);
|
|
156
|
+
}
|
|
157
|
+
innerOffset += inline.raw.length;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
offset += token.raw.length;
|
|
161
|
+
}
|
|
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);
|
|
195
|
+
const inlineAt = (index: number) => inlineTargets.get(index);
|
|
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 }) => {
|
|
197
|
+
const sourceText = lines.slice(start, end).join("");
|
|
198
|
+
if (blockKind === "quote") {
|
|
199
|
+
rawText = lines.slice(start, end).map(line => line.replace(/^ {0,3}>[ \t]?/, "")).join("");
|
|
200
|
+
if (!rawText.trim()) return;
|
|
201
|
+
}
|
|
202
|
+
blocks.push({ kind: "block", sourceStart: start, sourceEnd: end, id: `${message.id}:block:${start}`, messageId: message.id, message, blockKind, rawText, language, sourceText, fence });
|
|
203
|
+
};
|
|
204
|
+
let i = 0;
|
|
205
|
+
while (i < lines.length) {
|
|
206
|
+
if (!content(lines[i]!).trim()) { i++; continue; }
|
|
207
|
+
listContext(lines[i]!);
|
|
208
|
+
const start = i;
|
|
209
|
+
const opening = fence(lines[i]!);
|
|
210
|
+
if (opening && !(opening[3]![0] === "`" && opening[4]!.includes("`"))) {
|
|
211
|
+
rich = true;
|
|
212
|
+
const marker = opening[3]!;
|
|
213
|
+
const indent = opening[1]!.length + (opening[2] ? opening[2].length + 1 : 0);
|
|
214
|
+
const quotePrefix = /^(?: {0,3}>[ \t]?)+/.exec(lines[i]!)?.[0] ?? "";
|
|
215
|
+
const quoteDepth = (quotePrefix.match(/>/g) ?? []).length;
|
|
216
|
+
const removeContainer = (line: string) => {
|
|
217
|
+
for (let depth = 0; depth < quoteDepth; depth++) line = line.replace(/^ {0,3}>[ \t]?/, "");
|
|
218
|
+
return line;
|
|
219
|
+
};
|
|
220
|
+
const containerIndent = listIndents.at(-1) ?? 0;
|
|
221
|
+
const payload: string[] = [];
|
|
222
|
+
let closingFence: string | undefined;
|
|
223
|
+
let externalBlankStart: number | undefined;
|
|
224
|
+
i++;
|
|
225
|
+
for (; i < lines.length; i++) {
|
|
226
|
+
const prefix = /^(?: {0,3}>[ \t]?)+/.exec(lines[i]!)?.[0] ?? "";
|
|
227
|
+
if ((prefix.match(/>/g) ?? []).length < quoteDepth) break;
|
|
228
|
+
const line = removeContainer(lines[i]!);
|
|
229
|
+
const blank = !content(line).trim();
|
|
230
|
+
const lineIndent = /^ */.exec(line)![0].length;
|
|
231
|
+
if (!blank && lineIndent < containerIndent) break;
|
|
232
|
+
const closing = new RegExp(`^ {${containerIndent},${containerIndent + 3}}(\x60+|~+)[ \t]*$`).exec(content(line));
|
|
233
|
+
if (closing && closing[1]![0] === marker[0] && closing[1]!.length >= marker.length) { closingFence = content(lines[i]!); externalBlankStart = undefined; i++; break; }
|
|
234
|
+
if (!blank) externalBlankStart = undefined;
|
|
235
|
+
else if (lineIndent < containerIndent) externalBlankStart ??= payload.length;
|
|
236
|
+
payload.push(line.replace(new RegExp(`^ {0,${indent}}`), ""));
|
|
237
|
+
}
|
|
238
|
+
if (externalBlankStart !== undefined) payload.length = externalBlankStart;
|
|
239
|
+
add(start, i, "code", payload.join(""), opening[4]!.trim().split(/\s+/)[0] || undefined, { opening: content(lines[start]!), closing: closingFence });
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const indentation = codeIndent();
|
|
243
|
+
const indented = (line: string) => new RegExp(`^(?: {${indentation}}|\\t)`).test(line);
|
|
244
|
+
if (indented(lines[i]!)) {
|
|
245
|
+
rich = true;
|
|
246
|
+
i++;
|
|
247
|
+
while (i < lines.length && (indented(lines[i]!) || !content(lines[i]!).trim())) i++;
|
|
248
|
+
while (i > start && !content(lines[i - 1]!).trim()) i--;
|
|
249
|
+
add(start, i, "code", lines.slice(start, i).map(line => line.replace(new RegExp(`^(?: {${indentation}}|\\t)`), "")).join(""));
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const inline = inlineAt(i);
|
|
253
|
+
if (inline !== undefined) {
|
|
254
|
+
rich = true;
|
|
255
|
+
i++;
|
|
256
|
+
add(start, i, "inline-code", inline);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const first = content(lines[i]!);
|
|
260
|
+
const heading = /^ {0,3}#{1,6}(?:\s|$)/.test(first);
|
|
261
|
+
const rule = /^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/.test(first);
|
|
262
|
+
const list = /^ {0,3}(?:[-+*]|\d+[.)])\s/.test(first);
|
|
263
|
+
const quote = /^ {0,3}>/.test(first);
|
|
264
|
+
rich ||= structure(lines[i]!);
|
|
265
|
+
i++;
|
|
266
|
+
if (!heading && !rule) {
|
|
267
|
+
while (i < lines.length && content(lines[i]!).trim()) {
|
|
268
|
+
listContext(lines[i]!);
|
|
269
|
+
const continuation = protectedLines.has(i) || ((list || quote) && /^(?: {4}|\t)/.test(lines[i]!));
|
|
270
|
+
if ((!list && inlineAt(i) !== undefined) || (quote && /^ {0,3}>[ \t]*$/.test(content(lines[i - 1]!)) && !/^ {0,3}>/.test(content(lines[i]!))) || fence(lines[i]!) || (!continuation && new RegExp(`^ {${codeIndent()}}`).test(lines[i]!))) break;
|
|
271
|
+
if (/^ {0,3}(?:=+|-+)[ \t]*$/.test(content(lines[i]!)) && !list && !quote) { rich = true; i++; break; }
|
|
272
|
+
if (/^\s*\|?\s*:?-{3,}/.test(content(lines[i]!)) && first.includes("|")) rich = true;
|
|
273
|
+
else if (structure(lines[i]!) && !continuation && !(list && /^(?: {0,3}(?:[-+*]|\d+[.)])\s|\s{1,})/.test(lines[i]!)) && !(quote && /^ {0,3}>/.test(lines[i]!))) break;
|
|
274
|
+
i++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (quote) add(start, i, "quote");
|
|
278
|
+
}
|
|
279
|
+
return rich ? [parent, ...blocks] : [parent];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function copyText(target: PickTarget): string {
|
|
283
|
+
return target.kind === "block" && (target.blockKind === "code" || target.blockKind === "quote")
|
|
284
|
+
? target.rawText.replace(/(?:\r\n|\n|\r)+$/, "") : target.rawText;
|
|
285
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { copyToClipboard, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { TuiAltScreen } from "@earendil-works/pi-tui";
|
|
3
|
+
import { copyText, toPickerItems, toPickTargets } from "./history.js";
|
|
4
|
+
import { PickerScreen } from "./picker.js";
|
|
5
|
+
|
|
6
|
+
export default function copyPicker(pi: ExtensionAPI): void {
|
|
7
|
+
let generation = 0;
|
|
8
|
+
pi.on("session_start", () => { generation++; });
|
|
9
|
+
pi.on("session_shutdown", () => { generation++; });
|
|
10
|
+
let copying = false;
|
|
11
|
+
const copyBlock = async (ctx: ExtensionContext): Promise<void> => {
|
|
12
|
+
if (ctx.mode !== "tui") {
|
|
13
|
+
ctx.ui.notify("/copy-block requires interactive TUI mode", "error");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (copying) return;
|
|
17
|
+
const branch = ctx.sessionManager.getBranch();
|
|
18
|
+
const item = [...toPickerItems(branch)].reverse().find(item => item.kind === "assistant");
|
|
19
|
+
const targets = item ? toPickTargets(item) : [];
|
|
20
|
+
const target = targets.filter(target => target.kind === "block" && copyText(target).length > 0).at(-1)
|
|
21
|
+
?? (item?.rawText.length ? targets[0] : undefined);
|
|
22
|
+
if (!target) {
|
|
23
|
+
ctx.ui.notify("No copyable text in the latest assistant message", "warning");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
copying = true;
|
|
27
|
+
const current = generation;
|
|
28
|
+
try {
|
|
29
|
+
await copyToClipboard(copyText(target));
|
|
30
|
+
if (current === generation) ctx.ui.notify("Copied message to clipboard", "info");
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (current === generation) ctx.ui.notify(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
33
|
+
} finally {
|
|
34
|
+
copying = false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
pi.registerShortcut("ctrl+shift+b", { description: "Copy last assistant block", handler: copyBlock });
|
|
38
|
+
pi.registerCommand("copy-block", {
|
|
39
|
+
description: "Copy the last block or message from the latest assistant reply (Ctrl+Shift+B)",
|
|
40
|
+
handler: (_args, ctx) => copyBlock(ctx),
|
|
41
|
+
});
|
|
42
|
+
pi.registerCommand("copy-picker", {
|
|
43
|
+
description: "Browse and copy current-branch messages in fullscreen",
|
|
44
|
+
handler: async (_args, ctx) => {
|
|
45
|
+
if (ctx.mode !== "tui") {
|
|
46
|
+
ctx.ui.notify("/copy-picker requires interactive TUI mode", "error");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const current = ++generation;
|
|
50
|
+
const items = toPickerItems(ctx.sessionManager.getBranch());
|
|
51
|
+
let flash: (() => void) | undefined;
|
|
52
|
+
const result = await ctx.ui.custom<"copied" | undefined>((tui, theme, kb, done) => {
|
|
53
|
+
if (tui instanceof TuiAltScreen) flash = () => tui.flash("Copied!");
|
|
54
|
+
return new PickerScreen(items, tui, theme, kb, done);
|
|
55
|
+
}, {
|
|
56
|
+
overlay: true,
|
|
57
|
+
overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 },
|
|
58
|
+
});
|
|
59
|
+
if (result !== "copied" || current !== generation) return;
|
|
60
|
+
if (flash) flash();
|
|
61
|
+
else ctx.ui.notify("Copied message to clipboard", "info");
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
package/src/picker.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { copyToClipboard, getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Input, Key, Markdown, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi,
|
|
4
|
+
type Component, type Focusable, type KeybindingsManager, type TUI,
|
|
5
|
+
} from "@earendil-works/pi-tui";
|
|
6
|
+
import { copyText, toPickTargets, type PickItem, type PickTarget } from "./history.js";
|
|
7
|
+
|
|
8
|
+
type CopyState = { kind: "idle" | "pending" } | { kind: "error"; message: string };
|
|
9
|
+
type Interaction = { kind: "active"; selectedId: string | undefined }
|
|
10
|
+
| { kind: "unfocused" };
|
|
11
|
+
type LineStream = { lines: string[]; content: boolean[]; userBackground: boolean[]; ranges: { item: PickTarget; start: number; end: number; contentStart: number; contentEnd: number; userPadding?: { top: number; bottom: number } }[] };
|
|
12
|
+
|
|
13
|
+
function safeDisplay(text: string): string {
|
|
14
|
+
return stripTerminalSequences(text.replace(/\r\n?/g, "\n")).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fit(text: string, width: number): string {
|
|
18
|
+
const line = truncateToWidth(text, width, "");
|
|
19
|
+
return line + " ".repeat(Math.max(0, width - visibleWidth(line)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sourceSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
23
|
+
|
|
24
|
+
function wrapSource(line: string, width: number): string[] {
|
|
25
|
+
if (visibleWidth(line) <= width) return [line];
|
|
26
|
+
const rows: string[] = [];
|
|
27
|
+
let row = "";
|
|
28
|
+
let columns = 0;
|
|
29
|
+
let style = "";
|
|
30
|
+
for (const token of line.match(/\x1b\[[0-9;]*m|[^\x1b]+/g) ?? []) {
|
|
31
|
+
if (token.startsWith("\x1b")) {
|
|
32
|
+
row += token;
|
|
33
|
+
style = token === "\x1b[0m" ? "" : style + token;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
for (const { segment } of sourceSegmenter.segment(token)) {
|
|
37
|
+
const size = visibleWidth(segment);
|
|
38
|
+
if (columns + size > width) {
|
|
39
|
+
rows.push(row + (style ? "\x1b[0m" : ""));
|
|
40
|
+
row = style;
|
|
41
|
+
columns = 0;
|
|
42
|
+
}
|
|
43
|
+
row += segment;
|
|
44
|
+
columns += size;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
rows.push(row + (style ? "\x1b[0m" : ""));
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class PickerScreen implements Component, Focusable {
|
|
52
|
+
private readonly queryInput = new Input({ prompt: "type to search > " });
|
|
53
|
+
private interaction: Interaction = { kind: "active", selectedId: undefined };
|
|
54
|
+
private viewportStart = Infinity;
|
|
55
|
+
private readonly sources = new Map<string, { width: number; rows: string[][] }>();
|
|
56
|
+
private readonly lineStarts = new Map<string, number[]>();
|
|
57
|
+
private revealSelection = true;
|
|
58
|
+
private pageIndex: number | undefined;
|
|
59
|
+
|
|
60
|
+
private get selectedId(): string | undefined {
|
|
61
|
+
return this.interaction.kind === "active" ? this.interaction.selectedId : undefined;
|
|
62
|
+
}
|
|
63
|
+
private showTools = false;
|
|
64
|
+
private scrollTop = 0;
|
|
65
|
+
private copyState: CopyState = { kind: "idle" };
|
|
66
|
+
private disposed = false;
|
|
67
|
+
private width = 80;
|
|
68
|
+
private renderedRows: number | undefined;
|
|
69
|
+
private cachedWidth: number | undefined;
|
|
70
|
+
private readonly previews = new Map<string, { rawText: string; lines: string[]; codeLines: string[] }>();
|
|
71
|
+
private readonly targets = new Map<string, readonly PickTarget[]>();
|
|
72
|
+
private filtered: { query: string; tools: boolean; items: readonly PickItem[] } | undefined;
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly items: readonly PickItem[],
|
|
76
|
+
private readonly tui: TUI,
|
|
77
|
+
private readonly theme: Theme,
|
|
78
|
+
private readonly kb: KeybindingsManager,
|
|
79
|
+
private readonly close: (result?: "copied") => void,
|
|
80
|
+
private readonly copy: (text: string) => Promise<void> = copyToClipboard,
|
|
81
|
+
) {
|
|
82
|
+
this.select(this.visibleItems().at(-1)?.id);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get focused(): boolean { return this.queryInput.focused; }
|
|
86
|
+
set focused(value: boolean) { this.queryInput.focused = value; }
|
|
87
|
+
|
|
88
|
+
private visibleItems(): readonly PickItem[] {
|
|
89
|
+
const query = this.queryInput.getValue().toLowerCase();
|
|
90
|
+
if (this.filtered?.query === query && this.filtered.tools === this.showTools) return this.filtered.items;
|
|
91
|
+
const items = this.items.filter(item =>
|
|
92
|
+
(this.showTools || item.kind === "user" || item.kind === "assistant") &&
|
|
93
|
+
`${"toolName" in item ? item.toolName : ""}\n${item.rawText}`.toLowerCase().includes(query));
|
|
94
|
+
this.filtered = { query, tools: this.showTools, items };
|
|
95
|
+
return items;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private lineCountAt(message: PickItem, offset: number): number {
|
|
99
|
+
let starts = this.lineStarts.get(message.id);
|
|
100
|
+
if (!starts) {
|
|
101
|
+
starts = Array.from(message.rawText.matchAll(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g), match => match.index);
|
|
102
|
+
this.lineStarts.set(message.id, starts);
|
|
103
|
+
}
|
|
104
|
+
let low = 0, high = starts.length;
|
|
105
|
+
while (low < high) {
|
|
106
|
+
const mid = (low + high) >>> 1;
|
|
107
|
+
if (starts[mid]! < offset) low = mid + 1;
|
|
108
|
+
else high = mid;
|
|
109
|
+
}
|
|
110
|
+
return low;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private visibleTargets(items = this.visibleItems(), showTools = this.showTools): readonly PickTarget[] {
|
|
114
|
+
const events = items.flatMap((item, index) => {
|
|
115
|
+
let targets = this.targets.get(item.id);
|
|
116
|
+
if (!targets) { targets = toPickTargets(item); this.targets.set(item.id, targets); }
|
|
117
|
+
if (!showTools || !item.segments?.length) return [{ order: item.order ?? index * 1000, targets }];
|
|
118
|
+
return item.segments.map((segment, segmentIndex) => {
|
|
119
|
+
const start = this.lineCountAt(item, segment.sourceStart);
|
|
120
|
+
const end = segment.sourceEnd === item.rawText.length ? Infinity : this.lineCountAt(item, segment.sourceEnd);
|
|
121
|
+
return { order: segment.order, targets: targets!.filter(target => target.kind === "message"
|
|
122
|
+
? segmentIndex === 0 : target.sourceStart >= start && target.sourceStart < end) };
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
return events.sort((a, b) => a.order - b.order).flatMap(event => event.targets);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private select(id: string | undefined): void {
|
|
129
|
+
if (id !== this.selectedId) {
|
|
130
|
+
this.interaction = { kind: "active", selectedId: id };
|
|
131
|
+
this.scrollTop = 0;
|
|
132
|
+
this.pageIndex = undefined;
|
|
133
|
+
this.revealSelection = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private reconcile(): void {
|
|
138
|
+
if (this.interaction.kind !== "active") return;
|
|
139
|
+
const visible = this.visibleTargets();
|
|
140
|
+
if (visible.some(item => item.id === this.selectedId)) return;
|
|
141
|
+
const all = this.visibleTargets(this.items, true);
|
|
142
|
+
const previousIndex = all.findIndex(item => item.id === this.selectedId);
|
|
143
|
+
const visibleIds = new Set(visible.map(item => item.id));
|
|
144
|
+
const previous = all.slice(0, Math.max(0, previousIndex)).reverse()
|
|
145
|
+
.find(item => visibleIds.has(item.id));
|
|
146
|
+
this.select(previous?.id ?? visible[0]?.id);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private listRows(): number { return Math.floor(Math.max(0, this.tui.terminal.rows - 6) * 0.6); }
|
|
150
|
+
|
|
151
|
+
private windowTooSmall(): boolean {
|
|
152
|
+
const selected = this.visibleTargets().find(target => target.id === this.selectedId);
|
|
153
|
+
const minListRows = selected?.kind === "message" && selected.message.kind === "user" ? 5 : 3;
|
|
154
|
+
return this.width < 32 || this.listRows() < minListRows || this.previewRows() < 2;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private previewRows(): number { return Math.max(0, this.tui.terminal.rows - 6 - this.listRows()); }
|
|
158
|
+
|
|
159
|
+
private sourceLines(message: PickItem): string[] {
|
|
160
|
+
const lines = (message.rawText.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? [])
|
|
161
|
+
.map(line => safeDisplay(line.replace(/(?:\r\n|\r|\n)$/, "")).replace(/\t/g, " "));
|
|
162
|
+
const targets = this.targets.get(message.id) ?? toPickTargets(message);
|
|
163
|
+
for (const block of targets) {
|
|
164
|
+
if (block.kind !== "block" || block.blockKind !== "code") continue;
|
|
165
|
+
const payload = safeDisplay(block.rawText).replace(/\t/g, " ").split("\n");
|
|
166
|
+
const highlighted = getMarkdownTheme().highlightCode?.(payload.join("\n"), block.language);
|
|
167
|
+
const start = block.sourceStart + (block.fence ? 1 : 0);
|
|
168
|
+
for (let i = 0; i < payload.length && start + i < block.sourceEnd; i++) {
|
|
169
|
+
const original = lines[start + i]!;
|
|
170
|
+
const colored = highlighted?.[i];
|
|
171
|
+
if (payload[i] && original.endsWith(payload[i]!) && colored !== undefined && stripTerminalSequences(colored) === payload[i]) {
|
|
172
|
+
lines[start + i] = original.slice(0, original.length - payload[i]!.length) + colored;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private sourceRows(message: PickItem, width: number): string[][] {
|
|
180
|
+
const cached = this.sources.get(message.id);
|
|
181
|
+
if (cached?.width === width) return cached.rows;
|
|
182
|
+
const rows = this.sourceLines(message).map(line => wrapSource(line, Math.max(1, width - 4))
|
|
183
|
+
.map(text => this.body(message, text, width - 2)));
|
|
184
|
+
this.sources.set(message.id, { width, rows });
|
|
185
|
+
return rows;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private stream(width = this.width): LineStream {
|
|
189
|
+
const lines: string[] = [];
|
|
190
|
+
const content: boolean[] = [];
|
|
191
|
+
const userBackground: boolean[] = [];
|
|
192
|
+
const add = (isContent: boolean, ...rows: string[]) => { lines.push(...rows); content.push(...rows.map(() => isContent)); userBackground.push(...rows.map(() => false)); };
|
|
193
|
+
const addUser = (isContent: boolean, ...rows: string[]) => { add(isContent, ...rows); userBackground.fill(true, userBackground.length - rows.length); };
|
|
194
|
+
const ranges: LineStream["ranges"] = [];
|
|
195
|
+
this.visibleTargets();
|
|
196
|
+
const events = this.visibleItems().flatMap((message, index) => {
|
|
197
|
+
if (this.showTools && message.segments?.length) return message.segments.map(segment => ({ message,
|
|
198
|
+
start: this.lineCountAt(message, segment.sourceStart),
|
|
199
|
+
end: segment.sourceEnd === message.rawText.length ? this.sourceRows(message, width).length : this.lineCountAt(message, segment.sourceEnd), order: segment.order }));
|
|
200
|
+
return [{ message, start: 0, end: this.sourceRows(message, width).length, order: message.order ?? index * 1000 }];
|
|
201
|
+
}).sort((a, b) => a.order - b.order);
|
|
202
|
+
let previousTool = false;
|
|
203
|
+
const summarized = new Set<string>();
|
|
204
|
+
for (const { message, start: sourceStart, end: sourceEnd } of events) {
|
|
205
|
+
const group = this.targets.get(message.id)!;
|
|
206
|
+
const parent = group[0]!;
|
|
207
|
+
const selected = group.find(target => target.id === this.selectedId);
|
|
208
|
+
const firstSegment = !summarized.has(message.id);
|
|
209
|
+
const tool = message.kind === "tool-call" || message.kind === "tool-result";
|
|
210
|
+
if (selected?.kind === "message" && firstSegment) {
|
|
211
|
+
summarized.add(message.id);
|
|
212
|
+
if (lines.length && (tool || previousTool)) add(false, "");
|
|
213
|
+
if (tool) add(true, this.theme.fg("dim", ` Tool ${message.kind === "tool-call" ? "call" : "output"}: ${safeDisplay(message.toolName)}`));
|
|
214
|
+
const shared = lines.length > 0 && !tool && !previousTool;
|
|
215
|
+
if (shared) { lines.pop(); content.pop(); userBackground.pop(); }
|
|
216
|
+
const start = lines.length;
|
|
217
|
+
const card = this.card(selected, width, this.showTools && message.segments?.length ? message.rawText.split(/\r\n|\r|\n/).slice(sourceStart, sourceEnd).join("\n") : undefined,
|
|
218
|
+
this.showTools && !!message.segments?.slice(1).some(segment => message.rawText.slice(segment.sourceStart, segment.sourceEnd).trim()));
|
|
219
|
+
const padding = message.kind === "user" ? 1 : 0;
|
|
220
|
+
add(false, card[0]!);
|
|
221
|
+
if (padding) addUser(false, card[1]!);
|
|
222
|
+
(padding ? addUser : add)(true, ...card.slice(1 + padding, card.length - 1 - padding));
|
|
223
|
+
if (padding) addUser(false, card[card.length - 2]!);
|
|
224
|
+
add(false, card[card.length - 1]!);
|
|
225
|
+
ranges.push({ item: selected, start, end: start + card.length, contentStart: start + 1 + padding, contentEnd: start + card.length - 1 - padding });
|
|
226
|
+
previousTool = tool;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const border = (top: boolean) => this.theme.fg("dim", (top ? "╭" : "╰") + "─".repeat(width - 2) + (top ? "╮" : "╯"));
|
|
230
|
+
if (lines.length && (tool || previousTool)) add(false, "");
|
|
231
|
+
const shared = lines.length > 0 && !tool && !previousTool;
|
|
232
|
+
const start = lines.length - (shared ? 1 : 0);
|
|
233
|
+
if (!shared) add(false, tool ? border(true) : "");
|
|
234
|
+
previousTool = tool;
|
|
235
|
+
const top = lines.length;
|
|
236
|
+
const decorate = (line: string) => tool ? this.theme.fg("dim", "│") + line + this.theme.fg("dim", "│") : " " + line + " ";
|
|
237
|
+
if (message.kind === "user") addUser(false, decorate(this.body(message, "", width - 2)));
|
|
238
|
+
if (tool) add(true, decorate(this.body(message, `Tool ${message.kind === "tool-call" ? "call" : "output"}: ${safeDisplay(message.toolName)}`, width - 2)));
|
|
239
|
+
const rows = this.sourceRows(message, width);
|
|
240
|
+
let focusStart = -1;
|
|
241
|
+
let focusEnd = -1;
|
|
242
|
+
for (let source = sourceStart; source < sourceEnd; source++) {
|
|
243
|
+
if (selected?.kind === "block" && source === selected.sourceStart) {
|
|
244
|
+
focusStart = lines.length;
|
|
245
|
+
const card = this.card(selected, width);
|
|
246
|
+
add(true, card[0]!);
|
|
247
|
+
(message.kind === "user" ? addUser : add)(true, ...card.slice(1, -1));
|
|
248
|
+
add(true, card[card.length - 1]!);
|
|
249
|
+
focusEnd = lines.length;
|
|
250
|
+
source = selected.sourceEnd - 1;
|
|
251
|
+
} else if (selected?.kind !== "block" || source < selected.sourceStart || source >= selected.sourceEnd) {
|
|
252
|
+
(message.kind === "user" ? addUser : add)(true, ...rows[source]!.map(decorate));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const bottom = lines.length;
|
|
256
|
+
if (message.kind === "user") addUser(false, decorate(this.body(message, "", width - 2)));
|
|
257
|
+
add(false, tool ? border(false) : "");
|
|
258
|
+
ranges.push({ item: focusStart >= 0 ? selected! : parent, start: focusStart >= 0 ? focusStart : start,
|
|
259
|
+
end: focusStart >= 0 ? focusEnd : lines.length, contentStart: top, contentEnd: bottom,
|
|
260
|
+
userPadding: message.kind === "user" ? { top, bottom } : undefined });
|
|
261
|
+
}
|
|
262
|
+
return { lines, content, userBackground, ranges };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private clampViewport(stream: LineStream): void {
|
|
266
|
+
const rows = Math.max(1, this.listRows());
|
|
267
|
+
const max = Math.max(0, stream.lines.length - rows);
|
|
268
|
+
this.viewportStart = Math.max(0, Math.min(max, this.viewportStart));
|
|
269
|
+
if (this.revealSelection && this.interaction.kind === "active") {
|
|
270
|
+
const target = this.visibleTargets().find(item => item.id === this.selectedId);
|
|
271
|
+
const range = stream.ranges.find(entry => entry.item.id === target?.id) ?? stream.ranges.find(entry => entry.item.messageId === target?.messageId);
|
|
272
|
+
if (range) {
|
|
273
|
+
if (range.end - range.start > rows) this.viewportStart = range.start;
|
|
274
|
+
else this.viewportStart = range.start - Math.floor((rows - (range.end - range.start)) / 2);
|
|
275
|
+
this.viewportStart = Math.max(0, Math.min(max, this.viewportStart));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
this.revealSelection = false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private pageMove(delta: number): void {
|
|
282
|
+
const stream = this.stream();
|
|
283
|
+
this.clampViewport(stream);
|
|
284
|
+
const rows = Math.max(1, this.listRows());
|
|
285
|
+
const max = Math.max(0, stream.lines.length - rows);
|
|
286
|
+
const currentPage = this.pageIndex ?? (this.viewportStart === max ? Math.ceil(stream.lines.length / rows) - 1 : Math.ceil(this.viewportStart / rows));
|
|
287
|
+
const nextPage = Math.max(0, Math.min(Math.ceil(stream.lines.length / rows) - 1, currentPage + delta));
|
|
288
|
+
const start = Math.max(0, Math.min(max, this.viewportStart + delta * rows));
|
|
289
|
+
if (start === this.viewportStart) return;
|
|
290
|
+
this.viewportStart = start;
|
|
291
|
+
if (this.interaction.kind !== "active") { this.pageIndex = nextPage; return; }
|
|
292
|
+
const visible = stream.ranges.filter(range => range.contentStart < start + rows && range.contentEnd > start);
|
|
293
|
+
const choice = start === 0 ? visible[0] : start === max ? visible.at(-1)
|
|
294
|
+
: visible.find(range => range.item.id === this.selectedId && range.start >= start && range.end <= start + rows) ?? visible[0];
|
|
295
|
+
if (!choice) return;
|
|
296
|
+
this.select(choice.item.id);
|
|
297
|
+
const updated = this.stream();
|
|
298
|
+
const selected = updated.ranges.find(range => range.item.id === this.selectedId);
|
|
299
|
+
const updatedMax = Math.max(0, updated.lines.length - rows);
|
|
300
|
+
if (selected) {
|
|
301
|
+
// Keep the same source anchor when replacing the selected card changes row counts.
|
|
302
|
+
const anchored = start === max ? updatedMax : start === 0 ? 0 : start + selected.start - choice.start;
|
|
303
|
+
this.viewportStart = Math.max(0, Math.min(updatedMax,
|
|
304
|
+
Math.min(selected.start, Math.max(anchored, selected.end - rows))));
|
|
305
|
+
}
|
|
306
|
+
this.revealSelection = false;
|
|
307
|
+
this.pageIndex = nextPage;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private previewLines(item: PickTarget | undefined, width: number): string[] {
|
|
311
|
+
if (!item) return ["No matching messages"];
|
|
312
|
+
if (!item.rawText.length) return ["No copyable text"];
|
|
313
|
+
if (this.cachedWidth !== width) {
|
|
314
|
+
this.previews.clear();
|
|
315
|
+
this.cachedWidth = width;
|
|
316
|
+
}
|
|
317
|
+
let cached = this.previews.get(item.id);
|
|
318
|
+
if (!cached || cached.rawText !== item.rawText) {
|
|
319
|
+
const markdownTheme = getMarkdownTheme();
|
|
320
|
+
const codeLines: string[] = [];
|
|
321
|
+
const code = item.kind === "block" && item.blockKind === "code";
|
|
322
|
+
const source = safeDisplay(item.kind === "block" && (item.blockKind === "quote" || item.blockKind === "inline-code") ? item.sourceText : item.rawText).replace(/\t/g, " ");
|
|
323
|
+
if (code) codeLines.push(...(markdownTheme.highlightCode?.(source, item.language)
|
|
324
|
+
?? source.split("\n").map(line => markdownTheme.codeBlock(line))));
|
|
325
|
+
const lines = code
|
|
326
|
+
? codeLines.flatMap(line => wrapTextWithAnsi(line, width))
|
|
327
|
+
: new Markdown(source, 0, 0, {
|
|
328
|
+
...markdownTheme,
|
|
329
|
+
highlightCode: (code, lang) => {
|
|
330
|
+
const highlighted = markdownTheme.highlightCode?.(code, lang)
|
|
331
|
+
?? code.split("\n").map(line => markdownTheme.codeBlock(line));
|
|
332
|
+
codeLines.push("", ...highlighted, "");
|
|
333
|
+
return highlighted;
|
|
334
|
+
},
|
|
335
|
+
}).render(width);
|
|
336
|
+
cached = { rawText: item.rawText, lines, codeLines };
|
|
337
|
+
this.previews.set(item.id, cached);
|
|
338
|
+
}
|
|
339
|
+
return cached.lines;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private scroll(delta: number): void {
|
|
343
|
+
if (this.interaction.kind !== "active") return;
|
|
344
|
+
const item = this.visibleTargets().find(item => item.id === this.selectedId);
|
|
345
|
+
const lines = this.previewLines(item, Math.max(1, this.width - 2));
|
|
346
|
+
const max = Math.max(0, lines.length - this.previewRows());
|
|
347
|
+
this.scrollTop = Math.max(0, Math.min(max, this.scrollTop + delta));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private move(delta: number): void {
|
|
351
|
+
if (this.interaction.kind === "unfocused") {
|
|
352
|
+
const stream = this.stream();
|
|
353
|
+
this.clampViewport(stream);
|
|
354
|
+
const visible = stream.ranges.filter(range => range.contentStart < this.viewportStart + this.listRows() && range.contentEnd > this.viewportStart);
|
|
355
|
+
const item = delta < 0 ? visible.at(-1)?.item : visible[0]?.item;
|
|
356
|
+
if (item) this.select(item.id);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const visible = this.visibleTargets();
|
|
360
|
+
const index = visible.findIndex(item => item.id === this.selectedId);
|
|
361
|
+
this.select(visible[Math.max(0, Math.min(visible.length - 1, index + delta))]?.id);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
handleInput(data: string): void {
|
|
365
|
+
if (this.disposed) return;
|
|
366
|
+
if (matchesKey(data, Key.escape) || this.kb.matches(data, "tui.select.cancel")) {
|
|
367
|
+
this.dispose();
|
|
368
|
+
this.close();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const baseTooSmall = this.windowTooSmall();
|
|
372
|
+
if (baseTooSmall && (
|
|
373
|
+
[Key.enter, Key.ctrl("t"), Key.pageUp, Key.pageDown, Key.shift("up"), Key.shift("down"),
|
|
374
|
+
Key.ctrl("home"), Key.ctrl("end"), Key.up, Key.down, Key.left, Key.right].some(key => matchesKey(data, key))
|
|
375
|
+
|| this.kb.matches(data, "tui.select.confirm") || this.kb.matches(data, "tui.input.submit")
|
|
376
|
+
|| this.kb.matches(data, "tui.select.up") || this.kb.matches(data, "tui.select.down")
|
|
377
|
+
|| [this.kb.getResolvedBindings()["ui.editor.submit"]].flat().some(key => key !== undefined && matchesKey(data, key))
|
|
378
|
+
)) return;
|
|
379
|
+
if (matchesKey(data, Key.enter)) {
|
|
380
|
+
void this.copySelected();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (matchesKey(data, Key.ctrl("t"))) {
|
|
384
|
+
this.showTools = !this.showTools;
|
|
385
|
+
this.pageIndex = undefined;
|
|
386
|
+
this.reconcile();
|
|
387
|
+
this.revealSelection = true;
|
|
388
|
+
} else if (matchesKey(data, Key.pageUp)) this.scroll(-(this.previewRows() - 1));
|
|
389
|
+
else if (matchesKey(data, Key.pageDown)) this.scroll(this.previewRows() - 1);
|
|
390
|
+
else if (matchesKey(data, Key.shift("up"))) this.scroll(-1);
|
|
391
|
+
else if (matchesKey(data, Key.shift("down"))) this.scroll(1);
|
|
392
|
+
else if (matchesKey(data, Key.ctrl("home"))) this.scroll(-Infinity);
|
|
393
|
+
else if (matchesKey(data, Key.ctrl("end"))) this.scroll(Infinity);
|
|
394
|
+
else if (matchesKey(data, Key.up)) this.move(-1);
|
|
395
|
+
else if (matchesKey(data, Key.down)) this.move(1);
|
|
396
|
+
else if (matchesKey(data, Key.left)) this.pageMove(-1);
|
|
397
|
+
else if (matchesKey(data, Key.right)) this.pageMove(1);
|
|
398
|
+
else if (this.kb.matches(data, "tui.select.confirm") || this.kb.matches(data, "tui.input.submit")
|
|
399
|
+
|| [this.kb.getResolvedBindings()["ui.editor.submit"]].flat().some(key => key !== undefined && matchesKey(data, key))) {
|
|
400
|
+
void this.copySelected();
|
|
401
|
+
return;
|
|
402
|
+
} else if (this.kb.matches(data, "tui.select.up")) this.move(-1);
|
|
403
|
+
else if (this.kb.matches(data, "tui.select.down")) this.move(1);
|
|
404
|
+
else {
|
|
405
|
+
const before = this.queryInput.getValue();
|
|
406
|
+
this.queryInput.handleInput(data);
|
|
407
|
+
const query = this.queryInput.getValue();
|
|
408
|
+
const safe = safeDisplay(query).replace(/[\n\t]/g, " ");
|
|
409
|
+
if (safe !== query) this.queryInput.setValue(safe);
|
|
410
|
+
if (safe !== before) {
|
|
411
|
+
this.interaction = { kind: "unfocused" };
|
|
412
|
+
this.viewportStart = safe ? 0 : Infinity;
|
|
413
|
+
this.pageIndex = undefined;
|
|
414
|
+
this.revealSelection = false;
|
|
415
|
+
this.scrollTop = 0;
|
|
416
|
+
if (this.copyState.kind === "error") this.copyState = { kind: "idle" };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
this.tui.requestRender();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private async copySelected(): Promise<void> {
|
|
423
|
+
if (this.copyState.kind === "pending") return;
|
|
424
|
+
if (this.interaction.kind !== "active") return;
|
|
425
|
+
const item = this.visibleTargets().find(item => item.id === this.selectedId);
|
|
426
|
+
if (!item?.rawText.length) return;
|
|
427
|
+
const rawText = copyText(item);
|
|
428
|
+
if (!rawText.length) return;
|
|
429
|
+
this.copyState = { kind: "pending" };
|
|
430
|
+
this.tui.requestRender();
|
|
431
|
+
try {
|
|
432
|
+
await this.copy(rawText);
|
|
433
|
+
if (this.disposed) return;
|
|
434
|
+
this.dispose();
|
|
435
|
+
this.close("copied");
|
|
436
|
+
return;
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (this.disposed) return;
|
|
439
|
+
this.copyState = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
|
440
|
+
}
|
|
441
|
+
this.tui.requestRender();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private status(): string {
|
|
445
|
+
if (this.copyState.kind === "pending") return "Copying...";
|
|
446
|
+
if (this.copyState.kind === "error") return `Copy failed: ${safeDisplay(this.copyState.message).replace(/\s+/g, " ")} · Enter to retry`;
|
|
447
|
+
return "";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private body(item: PickItem, line: string, width: number): string {
|
|
451
|
+
const text = fit(" " + line, width);
|
|
452
|
+
return item.kind === "user"
|
|
453
|
+
? this.theme.bg("userMessageBg", this.theme.fg("userMessageText", text)) : text;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private card(target: PickTarget, width: number, segmentSource?: string, hasLaterSource = false): string[] {
|
|
457
|
+
const block = target.kind === "block" ? target : undefined;
|
|
458
|
+
const inset = block ? " " : "";
|
|
459
|
+
width -= inset.length * 2;
|
|
460
|
+
const source = block && block.blockKind !== "inline-code"
|
|
461
|
+
? this.sourceLines(target.message).slice(block.sourceStart, block.sourceEnd)
|
|
462
|
+
: !block && segmentSource === undefined ? this.sourceLines(target.message)
|
|
463
|
+
: ((segmentSource ?? block?.sourceText ?? target.rawText).match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? [])
|
|
464
|
+
.map(line => safeDisplay(line.replace(/(?:\r\n|\r|\n)$/, "")).replace(/\t/g, " "));
|
|
465
|
+
const rows: { text: string; synthetic: boolean }[] = [];
|
|
466
|
+
for (let i = 0; i < source.length;) {
|
|
467
|
+
if (!stripTerminalSequences(source[i]!).trim()) {
|
|
468
|
+
let end = i + 1;
|
|
469
|
+
while (end < source.length && !stripTerminalSequences(source[end]!).trim()) end++;
|
|
470
|
+
if (end - i > 1) rows.push({ text: "...", synthetic: true });
|
|
471
|
+
else for (const text of wrapSource(source[i]!, Math.max(1, width - 4))) rows.push({ text, synthetic: false });
|
|
472
|
+
i = end;
|
|
473
|
+
} else {
|
|
474
|
+
for (const text of wrapSource(source[i]!, Math.max(1, width - 4))) rows.push({ text, synthetic: false });
|
|
475
|
+
i++;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (!rows.length) rows.push({ text: "No copyable text", synthetic: false });
|
|
479
|
+
const userPadding = !block && target.message.kind === "user";
|
|
480
|
+
const cap = Math.max(1, Math.min(7, this.listRows() - 2) - (userPadding ? 2 : 0));
|
|
481
|
+
if (rows.length > cap) {
|
|
482
|
+
rows.length = cap - 1;
|
|
483
|
+
rows.push({ text: "...", synthetic: true });
|
|
484
|
+
}
|
|
485
|
+
if (hasLaterSource && !rows.at(-1)?.synthetic) {
|
|
486
|
+
if (rows.length >= cap) rows.length = cap - 1;
|
|
487
|
+
rows.push({ text: "...", synthetic: true });
|
|
488
|
+
}
|
|
489
|
+
const markdown = getMarkdownTheme();
|
|
490
|
+
const excerpt = rows.map(row => row.synthetic ? this.theme.fg("muted", row.text)
|
|
491
|
+
: block?.blockKind === "inline-code" ? markdown.code(row.text) : row.text);
|
|
492
|
+
if (userPadding) { excerpt.unshift(""); excerpt.push(""); }
|
|
493
|
+
const border = (top: boolean) => this.theme.fg("accent", (top ? "╭" : "╰") + "─".repeat(width - 2) + (top ? "╮" : "╯"));
|
|
494
|
+
return [border(true), ...excerpt.map(line => this.theme.fg("accent", "│") + this.body(target.message, line, width - 2) + this.theme.fg("accent", "│")), border(false)].map(line => inset + line + inset);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
render(width: number): string[] {
|
|
498
|
+
const oldWidth = this.width;
|
|
499
|
+
this.width = Math.max(0, width);
|
|
500
|
+
const rows = Math.max(0, this.tui.terminal.rows);
|
|
501
|
+
const tooSmall = () => Array.from({ length: rows }, (_, i) => fit(i === 0 ? "Window too small. Esc to close." : "", this.width));
|
|
502
|
+
if (this.windowTooSmall()) return tooSmall();
|
|
503
|
+
this.reconcile();
|
|
504
|
+
const stream = this.stream(width);
|
|
505
|
+
if (this.renderedRows !== rows || oldWidth !== this.width) this.pageIndex = undefined;
|
|
506
|
+
this.revealSelection ||= this.renderedRows !== rows || oldWidth !== this.width;
|
|
507
|
+
this.renderedRows = rows;
|
|
508
|
+
this.clampViewport(stream);
|
|
509
|
+
const item = this.visibleTargets().find(target => target.id === this.selectedId);
|
|
510
|
+
const pageCount = Math.max(1, Math.ceil(stream.lines.length / this.listRows()));
|
|
511
|
+
const maxStart = Math.max(0, stream.lines.length - this.listRows());
|
|
512
|
+
const pageIndex = this.viewportStart === 0 ? 0 : this.viewportStart === maxStart ? pageCount - 1
|
|
513
|
+
: Math.min(pageCount - 1, this.pageIndex ?? Math.ceil(this.viewportStart / this.listRows()));
|
|
514
|
+
const help = "Enter copy · ←/→ prev/next page · Ctrl+T tools · PgUp/PgDn scroll preview · Esc close";
|
|
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) : "";
|
|
519
|
+
const prefix = `Page ${pageIndex + 1}/${pageCount} ${this.theme.fg("dim", `(tool messages ${this.showTools ? "included" : "excluded"})`)}, `;
|
|
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), "");
|
|
522
|
+
const arrow = (show: boolean, shaded: boolean) => {
|
|
523
|
+
if (!show) return "";
|
|
524
|
+
const text = " " + this.theme.fg("muted", "...");
|
|
525
|
+
return shaded ? " " + this.theme.bg("userMessageBg", fit(text, width - 2)) + " " : " " + text;
|
|
526
|
+
};
|
|
527
|
+
let visibleStart = this.viewportStart;
|
|
528
|
+
let visibleEnd = Math.min(stream.lines.length, visibleStart + this.listRows());
|
|
529
|
+
const padding = new Set(stream.ranges.flatMap(range => range.userPadding
|
|
530
|
+
? [ ...(range.contentStart + 1 >= visibleEnd ? [range.userPadding.top] : []),
|
|
531
|
+
...(range.contentEnd <= visibleStart ? [range.userPadding.bottom] : []) ] : []));
|
|
532
|
+
while (padding.has(visibleStart) && visibleStart < stream.lines.length) { visibleStart++; visibleEnd = Math.min(stream.lines.length, visibleStart + this.listRows()); }
|
|
533
|
+
while (padding.has(visibleEnd - 1) && visibleEnd > visibleStart) visibleEnd--;
|
|
534
|
+
const lines = [this.theme.fg("dim", help),
|
|
535
|
+
status ? this.theme.fg(this.copyState.kind === "error" ? "error" : "muted", status)
|
|
536
|
+
: shownPrefix + this.queryInput.render(Math.max(1, width - visibleWidth(shownPrefix) - visibleWidth(matchCount)))[0] + matchCount,
|
|
537
|
+
arrow(stream.content.slice(0, visibleStart).some(Boolean), !!stream.userBackground[visibleStart - 1] && !!stream.userBackground[visibleStart])];
|
|
538
|
+
const cardRows = stream.lines.slice(visibleStart, visibleEnd);
|
|
539
|
+
if (!stream.lines.length) cardRows.push(" No matching messages");
|
|
540
|
+
while (cardRows.length < this.listRows()) cardRows.push("");
|
|
541
|
+
lines.push(...cardRows, arrow(stream.content.slice(visibleEnd).some(Boolean), !!stream.userBackground[visibleEnd] && !!stream.userBackground[visibleEnd - 1]));
|
|
542
|
+
const label = " preview ";
|
|
543
|
+
const left = Math.floor((width - label.length) / 2);
|
|
544
|
+
lines.push(this.theme.fg("dim", "─".repeat(left) + label + "─".repeat(width - label.length - left)));
|
|
545
|
+
lines.push("");
|
|
546
|
+
if (this.interaction.kind !== "active") {
|
|
547
|
+
const prompt = "Use ↑/↓ to select a message";
|
|
548
|
+
const usable = rows - lines.length;
|
|
549
|
+
const middle = Math.floor((usable - 1) / 2);
|
|
550
|
+
for (let row = 0; row < usable; row++) {
|
|
551
|
+
lines.push(row === middle
|
|
552
|
+
? " ".repeat(Math.max(0, Math.floor((width - visibleWidth(prompt)) / 2))) + this.theme.fg("dim", prompt)
|
|
553
|
+
: "");
|
|
554
|
+
}
|
|
555
|
+
return lines.map(line => fit(line, width));
|
|
556
|
+
}
|
|
557
|
+
const preview = this.previewLines(item, Math.max(1, width - 2));
|
|
558
|
+
const count = this.previewRows();
|
|
559
|
+
this.scrollTop = Math.max(0, Math.min(this.scrollTop, preview.length - count));
|
|
560
|
+
const viewport = preview.slice(this.scrollTop, this.scrollTop + count);
|
|
561
|
+
if (this.scrollTop + count < preview.length) viewport[count - 1] = this.theme.fg("muted", "...");
|
|
562
|
+
lines.push(...viewport.map(line => " " + line));
|
|
563
|
+
while (lines.length < rows) lines.push("");
|
|
564
|
+
return lines.map(line => fit(line, width));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
invalidate(): void {
|
|
568
|
+
this.previews.clear();
|
|
569
|
+
this.sources.clear();
|
|
570
|
+
this.cachedWidth = undefined;
|
|
571
|
+
this.queryInput.invalidate();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
dispose(): void {
|
|
575
|
+
this.disposed = true;
|
|
576
|
+
this.previews.clear();
|
|
577
|
+
this.sources.clear();
|
|
578
|
+
this.lineStarts.clear();
|
|
579
|
+
}
|
|
580
|
+
}
|