pi-copy-picker 0.0.0-stage → 1.0.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +127 -2
- package/RELEASING.md +68 -0
- package/gallery-preview.png +0 -0
- package/package.json +64 -4
- package/src/history.ts +277 -0
- package/src/index.ts +64 -0
- package/src/picker.ts +577 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 1.0.0 - 2026-10-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Fullscreen current-branch message picker with search, source context, Markdown preview, and clipboard copy.
|
|
10
|
+
- Block selection for code, quotes, and standalone inline code, plus optional tool calls and outputs.
|
|
11
|
+
- `/copy-block` and `Ctrl+Shift+B` to copy the latest assistant block or message.
|
|
12
|
+
- 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,128 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi copy picker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Browse and copy messages from the current Pi branch with `/copy-picker`.
|
|
6
|
+
The fullscreen picker has a paginated message list above a Markdown preview.
|
|
7
|
+
It selects the newest visible message and closes after a successful copy.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
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.
|
|
13
|
+
|
|
14
|
+
## Open the picker
|
|
15
|
+
|
|
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:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pi install npm:pi-copy-picker
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Run `/copy-picker` in Pi. Use `/reload` or restart an existing session after installation or source changes.
|
|
36
|
+
The command does not change history or tool configuration.
|
|
37
|
+
|
|
38
|
+
## Copy the last block
|
|
39
|
+
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
## Controls
|
|
45
|
+
|
|
46
|
+
| Key | Action |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| 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 |
|
|
49
|
+
| Left / Right | Change page. Select its first message only when a selection is already active |
|
|
50
|
+
| Type | Edit the search, ignoring case, and clear selection and preview immediately |
|
|
51
|
+
| Ctrl+A / Ctrl+E | Move the search cursor to the start or end |
|
|
52
|
+
| Ctrl+U / Ctrl+K | Delete search text before or after the cursor |
|
|
53
|
+
| Ctrl+T | Show or hide tool calls and results, hidden by default |
|
|
54
|
+
| Enter | Copy an active selection and close on success. Do nothing when no message is selected |
|
|
55
|
+
| PgUp / PgDown | Scroll the preview by one page |
|
|
56
|
+
| Shift+Up / Shift+Down | Scroll the preview by one line |
|
|
57
|
+
| Ctrl+Home / Ctrl+End | Jump to the start or end of the preview |
|
|
58
|
+
| Esc | Close, even during a pending copy or an active search |
|
|
59
|
+
|
|
60
|
+
Pi's configured selection, confirm, and cancel keys also work. Fixed keys take priority.
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
## Message and clipboard behavior
|
|
64
|
+
|
|
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.
|
|
94
|
+
|
|
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.
|
|
98
|
+
|
|
99
|
+
## Develop in a local sandbox
|
|
100
|
+
|
|
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:
|
|
104
|
+
|
|
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
|
+
```
|
|
113
|
+
|
|
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.
|
|
124
|
+
|
|
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.
|
|
127
|
+
|
|
128
|
+
Maintained by tallshort. Repository: [tallshort/pi-copy-picker](https://github.com/tallshort/pi-copy-picker).
|
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 README's isolated install and check procedure. From the checked sandbox 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. No publication has been performed by this preparation task.
|
|
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.0.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,277 @@
|
|
|
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
|
+
// 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];
|
|
110
|
+
}
|
|
111
|
+
const lines = message.rawText.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) ?? [];
|
|
112
|
+
const blocks: PickTarget[] = [];
|
|
113
|
+
let rich = false;
|
|
114
|
+
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
|
+
const inlineCode = (line: string): string | undefined => {
|
|
134
|
+
const text = content(line);
|
|
135
|
+
const opener = /^ {0,3}(`+)(?!`)/.exec(text);
|
|
136
|
+
if (!opener) return undefined;
|
|
137
|
+
const runs = /`+/g;
|
|
138
|
+
runs.lastIndex = opener[0].length;
|
|
139
|
+
for (let match = runs.exec(text); match; match = runs.exec(text)) {
|
|
140
|
+
if (match[0].length !== opener[1]!.length) continue;
|
|
141
|
+
const payload = text.slice(opener[0].length, match.index);
|
|
142
|
+
return /^[ \t]*$/.test(text.slice(match.index + match[0].length)) && payload.trim() ? payload : undefined;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
};
|
|
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
|
+
const inlineCandidates = new Map<number, { line: number; raw: string; payload: string }>();
|
|
148
|
+
let sourceOffset = 0;
|
|
149
|
+
for (let line = 0; line < lines.length; line++) {
|
|
150
|
+
const text = content(lines[line]!);
|
|
151
|
+
const payload = inlineCode(lines[line]!);
|
|
152
|
+
if (payload !== undefined) {
|
|
153
|
+
const indent = /^ */.exec(text)![0].length;
|
|
154
|
+
inlineCandidates.set(sourceOffset + indent, { line, raw: text.slice(indent).trimEnd(), payload });
|
|
155
|
+
}
|
|
156
|
+
sourceOffset += lines[line]!.replace(/\r\n|\r/g, "\n").length;
|
|
157
|
+
}
|
|
158
|
+
const inlineTargets = new Map<number, string>();
|
|
159
|
+
const normalized = message.rawText.replace(/\r\n|\r/g, "\n");
|
|
160
|
+
const protectedLines = new Set<number>();
|
|
161
|
+
{
|
|
162
|
+
let offset = 0;
|
|
163
|
+
let line = 0;
|
|
164
|
+
for (const token of new Marked().lexer(normalized)) {
|
|
165
|
+
if (!normalized.startsWith(token.raw, offset)) break;
|
|
166
|
+
const count = (token.raw.match(/\n/g) ?? []).length;
|
|
167
|
+
if (token.type === "paragraph" || token.type === "html") {
|
|
168
|
+
for (let n = 1; n <= count; n++) {
|
|
169
|
+
if (n < count || !token.raw.endsWith("\n")) protectedLines.add(line + n);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
line += count;
|
|
173
|
+
if (token.type === "paragraph") {
|
|
174
|
+
let innerOffset = offset;
|
|
175
|
+
for (const inline of token.tokens ?? []) {
|
|
176
|
+
if (!normalized.startsWith(inline.raw, innerOffset)) break;
|
|
177
|
+
const candidate = inlineCandidates.get(innerOffset);
|
|
178
|
+
if (inline.type === "codespan" && candidate?.raw === inline.raw) {
|
|
179
|
+
inlineTargets.set(candidate.line, candidate.payload);
|
|
180
|
+
}
|
|
181
|
+
innerOffset += inline.raw.length;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
offset += token.raw.length;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const inlineAt = (index: number) => inlineTargets.get(index);
|
|
188
|
+
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
|
+
const sourceText = lines.slice(start, end).join("");
|
|
190
|
+
if (blockKind === "quote") {
|
|
191
|
+
rawText = lines.slice(start, end).map(line => line.replace(/^ {0,3}>[ \t]?/, "")).join("");
|
|
192
|
+
if (!rawText.trim()) return;
|
|
193
|
+
}
|
|
194
|
+
blocks.push({ kind: "block", sourceStart: start, sourceEnd: end, id: `${message.id}:block:${start}`, messageId: message.id, message, blockKind, rawText, language, sourceText, fence });
|
|
195
|
+
};
|
|
196
|
+
let i = 0;
|
|
197
|
+
while (i < lines.length) {
|
|
198
|
+
if (!content(lines[i]!).trim()) { i++; continue; }
|
|
199
|
+
listContext(lines[i]!);
|
|
200
|
+
const start = i;
|
|
201
|
+
const opening = fence(lines[i]!);
|
|
202
|
+
if (opening && !(opening[3]![0] === "`" && opening[4]!.includes("`"))) {
|
|
203
|
+
rich = true;
|
|
204
|
+
const marker = opening[3]!;
|
|
205
|
+
const indent = opening[1]!.length + (opening[2] ? opening[2].length + 1 : 0);
|
|
206
|
+
const quotePrefix = /^(?: {0,3}>[ \t]?)+/.exec(lines[i]!)?.[0] ?? "";
|
|
207
|
+
const quoteDepth = (quotePrefix.match(/>/g) ?? []).length;
|
|
208
|
+
const removeContainer = (line: string) => {
|
|
209
|
+
for (let depth = 0; depth < quoteDepth; depth++) line = line.replace(/^ {0,3}>[ \t]?/, "");
|
|
210
|
+
return line;
|
|
211
|
+
};
|
|
212
|
+
const containerIndent = listIndents.at(-1) ?? 0;
|
|
213
|
+
const payload: string[] = [];
|
|
214
|
+
let closingFence: string | undefined;
|
|
215
|
+
let externalBlankStart: number | undefined;
|
|
216
|
+
i++;
|
|
217
|
+
for (; i < lines.length; i++) {
|
|
218
|
+
const prefix = /^(?: {0,3}>[ \t]?)+/.exec(lines[i]!)?.[0] ?? "";
|
|
219
|
+
if ((prefix.match(/>/g) ?? []).length < quoteDepth) break;
|
|
220
|
+
const line = removeContainer(lines[i]!);
|
|
221
|
+
const blank = !content(line).trim();
|
|
222
|
+
const lineIndent = /^ */.exec(line)![0].length;
|
|
223
|
+
if (!blank && lineIndent < containerIndent) break;
|
|
224
|
+
const closing = new RegExp(`^ {${containerIndent},${containerIndent + 3}}(\x60+|~+)[ \t]*$`).exec(content(line));
|
|
225
|
+
if (closing && closing[1]![0] === marker[0] && closing[1]!.length >= marker.length) { closingFence = content(lines[i]!); externalBlankStart = undefined; i++; break; }
|
|
226
|
+
if (!blank) externalBlankStart = undefined;
|
|
227
|
+
else if (lineIndent < containerIndent) externalBlankStart ??= payload.length;
|
|
228
|
+
payload.push(line.replace(new RegExp(`^ {0,${indent}}`), ""));
|
|
229
|
+
}
|
|
230
|
+
if (externalBlankStart !== undefined) payload.length = externalBlankStart;
|
|
231
|
+
add(start, i, "code", payload.join(""), opening[4]!.trim().split(/\s+/)[0] || undefined, { opening: content(lines[start]!), closing: closingFence });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const indentation = codeIndent();
|
|
235
|
+
const indented = (line: string) => new RegExp(`^(?: {${indentation}}|\\t)`).test(line);
|
|
236
|
+
if (indented(lines[i]!)) {
|
|
237
|
+
rich = true;
|
|
238
|
+
i++;
|
|
239
|
+
while (i < lines.length && (indented(lines[i]!) || !content(lines[i]!).trim())) i++;
|
|
240
|
+
while (i > start && !content(lines[i - 1]!).trim()) i--;
|
|
241
|
+
add(start, i, "code", lines.slice(start, i).map(line => line.replace(new RegExp(`^(?: {${indentation}}|\\t)`), "")).join(""));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const inline = inlineAt(i);
|
|
245
|
+
if (inline !== undefined) {
|
|
246
|
+
rich = true;
|
|
247
|
+
i++;
|
|
248
|
+
add(start, i, "inline-code", inline);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const first = content(lines[i]!);
|
|
252
|
+
const heading = /^ {0,3}#{1,6}(?:\s|$)/.test(first);
|
|
253
|
+
const rule = /^ {0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/.test(first);
|
|
254
|
+
const list = /^ {0,3}(?:[-+*]|\d+[.)])\s/.test(first);
|
|
255
|
+
const quote = /^ {0,3}>/.test(first);
|
|
256
|
+
rich ||= structure(lines[i]!);
|
|
257
|
+
i++;
|
|
258
|
+
if (!heading && !rule) {
|
|
259
|
+
while (i < lines.length && content(lines[i]!).trim()) {
|
|
260
|
+
listContext(lines[i]!);
|
|
261
|
+
const continuation = protectedLines.has(i) || ((list || quote) && /^(?: {4}|\t)/.test(lines[i]!));
|
|
262
|
+
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;
|
|
263
|
+
if (/^ {0,3}(?:=+|-+)[ \t]*$/.test(content(lines[i]!)) && !list && !quote) { rich = true; i++; break; }
|
|
264
|
+
if (/^\s*\|?\s*:?-{3,}/.test(content(lines[i]!)) && first.includes("|")) rich = true;
|
|
265
|
+
else if (structure(lines[i]!) && !continuation && !(list && /^(?: {0,3}(?:[-+*]|\d+[.)])\s|\s{1,})/.test(lines[i]!)) && !(quote && /^ {0,3}>/.test(lines[i]!))) break;
|
|
266
|
+
i++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (quote) add(start, i, "quote");
|
|
270
|
+
}
|
|
271
|
+
return rich ? [parent, ...blocks] : [parent];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function copyText(target: PickTarget): string {
|
|
275
|
+
return target.kind === "block" && (target.blockKind === "code" || target.blockKind === "quote")
|
|
276
|
+
? target.rawText.replace(/(?:\r\n|\n|\r)+$/, "") : target.rawText;
|
|
277
|
+
}
|
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,577 @@
|
|
|
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 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), "");
|
|
519
|
+
const arrow = (show: boolean, shaded: boolean) => {
|
|
520
|
+
if (!show) return "";
|
|
521
|
+
const text = " " + this.theme.fg("muted", "...");
|
|
522
|
+
return shaded ? " " + this.theme.bg("userMessageBg", fit(text, width - 2)) + " " : " " + text;
|
|
523
|
+
};
|
|
524
|
+
let visibleStart = this.viewportStart;
|
|
525
|
+
let visibleEnd = Math.min(stream.lines.length, visibleStart + this.listRows());
|
|
526
|
+
const padding = new Set(stream.ranges.flatMap(range => range.userPadding
|
|
527
|
+
? [ ...(range.contentStart + 1 >= visibleEnd ? [range.userPadding.top] : []),
|
|
528
|
+
...(range.contentEnd <= visibleStart ? [range.userPadding.bottom] : []) ] : []));
|
|
529
|
+
while (padding.has(visibleStart) && visibleStart < stream.lines.length) { visibleStart++; visibleEnd = Math.min(stream.lines.length, visibleStart + this.listRows()); }
|
|
530
|
+
while (padding.has(visibleEnd - 1) && visibleEnd > visibleStart) visibleEnd--;
|
|
531
|
+
const lines = [this.theme.fg("dim", help),
|
|
532
|
+
status ? this.theme.fg(this.copyState.kind === "error" ? "error" : "muted", status)
|
|
533
|
+
: shownPrefix + this.queryInput.render(Math.max(1, width - visibleWidth(shownPrefix)))[0],
|
|
534
|
+
arrow(stream.content.slice(0, visibleStart).some(Boolean), !!stream.userBackground[visibleStart - 1] && !!stream.userBackground[visibleStart])];
|
|
535
|
+
const cardRows = stream.lines.slice(visibleStart, visibleEnd);
|
|
536
|
+
if (!stream.lines.length) cardRows.push(" No matching messages");
|
|
537
|
+
while (cardRows.length < this.listRows()) cardRows.push("");
|
|
538
|
+
lines.push(...cardRows, arrow(stream.content.slice(visibleEnd).some(Boolean), !!stream.userBackground[visibleEnd] && !!stream.userBackground[visibleEnd - 1]));
|
|
539
|
+
const label = " preview ";
|
|
540
|
+
const left = Math.floor((width - label.length) / 2);
|
|
541
|
+
lines.push(this.theme.fg("dim", "─".repeat(left) + label + "─".repeat(width - label.length - left)));
|
|
542
|
+
lines.push("");
|
|
543
|
+
if (this.interaction.kind !== "active") {
|
|
544
|
+
const prompt = "Use ↑/↓ to select a message";
|
|
545
|
+
const usable = rows - lines.length;
|
|
546
|
+
const middle = Math.floor((usable - 1) / 2);
|
|
547
|
+
for (let row = 0; row < usable; row++) {
|
|
548
|
+
lines.push(row === middle
|
|
549
|
+
? " ".repeat(Math.max(0, Math.floor((width - visibleWidth(prompt)) / 2))) + this.theme.fg("dim", prompt)
|
|
550
|
+
: "");
|
|
551
|
+
}
|
|
552
|
+
return lines.map(line => fit(line, width));
|
|
553
|
+
}
|
|
554
|
+
const preview = this.previewLines(item, Math.max(1, width - 2));
|
|
555
|
+
const count = this.previewRows();
|
|
556
|
+
this.scrollTop = Math.max(0, Math.min(this.scrollTop, preview.length - count));
|
|
557
|
+
const viewport = preview.slice(this.scrollTop, this.scrollTop + count);
|
|
558
|
+
if (this.scrollTop + count < preview.length) viewport[count - 1] = this.theme.fg("muted", "...");
|
|
559
|
+
lines.push(...viewport.map(line => " " + line));
|
|
560
|
+
while (lines.length < rows) lines.push("");
|
|
561
|
+
return lines.map(line => fit(line, width));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
invalidate(): void {
|
|
565
|
+
this.previews.clear();
|
|
566
|
+
this.sources.clear();
|
|
567
|
+
this.cachedWidth = undefined;
|
|
568
|
+
this.queryInput.invalidate();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
dispose(): void {
|
|
572
|
+
this.disposed = true;
|
|
573
|
+
this.previews.clear();
|
|
574
|
+
this.sources.clear();
|
|
575
|
+
this.lineStarts.clear();
|
|
576
|
+
}
|
|
577
|
+
}
|