pi-image-tools 1.0.10 → 1.0.11
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 +6 -24
- package/README.md +90 -7
- package/config/config.example.json +8 -3
- package/package.json +66 -67
- package/src/config.ts +146 -0
- package/src/debug-logger.ts +48 -0
- package/src/index.ts +10 -1
- package/src/keybindings.ts +258 -5
- package/src/sixel-protocol.ts +57 -57
- package/src/terminal-image-width.ts +90 -90
- package/asset/pi-image-tools.png +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,25 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
-
|
|
8
|
-
## [Unreleased]
|
|
9
|
-
|
|
10
|
-
## [1.0.10] - 2026-04-22
|
|
11
|
-
|
|
12
|
-
### Added
|
|
13
|
-
- Added preview width resolution from Pi terminal settings so inline and recent-image previews can honor `terminal.imageWidthCells`.
|
|
3
|
+
## [1.0.11] - 2026-04-25
|
|
14
4
|
|
|
15
5
|
### Changed
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.68.1.
|
|
20
|
-
|
|
21
|
-
### Fixed
|
|
22
|
-
- Ensured generated Sixel output is emitted as a complete DCS sequence and preserved inline image protocol rows during preview width fitting.
|
|
6
|
+
- Avoid Pi's built-in image paste shortcut by default while preserving the previous primary shortcut when users disable or rebind `app.clipboard.pasteImage` (thanks to @danielcherubini for reporting this in PR #3).
|
|
7
|
+
- Replaced the placeholder `enabled` config with validated `debug`, explicit `shortcuts.pasteImage`, configurable built-in conflict avoidance, and built-in warning suppression settings.
|
|
8
|
+
- Removed obsolete packaged README image assets now that the README uses a GitHub-hosted image.
|
|
23
9
|
|
|
24
10
|
## [1.0.9] - 2026-04-01
|
|
25
11
|
|
|
@@ -74,12 +60,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
74
60
|
- Rewrote README.md with professional documentation standards
|
|
75
61
|
- Added comprehensive feature documentation, configuration reference, and usage examples
|
|
76
62
|
|
|
77
|
-
##
|
|
78
|
-
|
|
79
|
-
- Included `asset/` in the npm package whitelist so README image assets ship in the tarball.
|
|
80
|
-
|
|
81
|
-
## [1.0.0] - 2026-03-02
|
|
63
|
+
## 1.0.0
|
|
82
64
|
|
|
83
65
|
- Standardized repository layout to `src/` + root shim entrypoint.
|
|
84
66
|
- Added TypeScript/Bundler project config, package metadata, and publish whitelist.
|
|
85
|
-
- Added standard docs, license, and
|
|
67
|
+
- Added standard docs, license, and initial runtime config files.
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# 🖼️ pi-image-tools
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-image-tools) [](LICENSE)
|
|
4
|
+
|
|
3
5
|
Image attachment and preview extension for the **Pi coding agent**.
|
|
4
6
|
|
|
5
7
|
`pi-image-tools` lets you attach clipboard images or recent screenshots to your next user message, then preview them inline in the TUI before the message is sent.
|
|
@@ -93,8 +95,16 @@ Selecting an entry queues that image for your next message.
|
|
|
93
95
|
|
|
94
96
|
| Platform | Shortcuts |
|
|
95
97
|
|----------|-----------|
|
|
96
|
-
| Windows | `Alt+V
|
|
97
|
-
| Linux / macOS | `
|
|
98
|
+
| Windows | `Ctrl+Alt+V`; `Alt+V` when Pi's built-in `app.clipboard.pasteImage` shortcut is disabled or rebound |
|
|
99
|
+
| Linux / macOS | `Alt+V`, `Ctrl+Alt+V`; `Ctrl+V` when Pi's built-in `app.clipboard.pasteImage` shortcut is disabled or rebound |
|
|
100
|
+
|
|
101
|
+
Pi's built-in image paste shortcut is not overridden by default. To keep the previous primary shortcut behavior without startup conflict warnings, disable the built-in binding manually in `~/.pi/agent/keybindings.json`:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"app.clipboard.pasteImage": []
|
|
106
|
+
}
|
|
107
|
+
```
|
|
98
108
|
|
|
99
109
|
## Configuration
|
|
100
110
|
|
|
@@ -124,12 +134,85 @@ Starter template:
|
|
|
124
134
|
|
|
125
135
|
```json
|
|
126
136
|
{
|
|
127
|
-
"
|
|
137
|
+
"debug": false,
|
|
138
|
+
"shortcuts": {
|
|
139
|
+
"pasteImage": ["ctrl+alt+v"],
|
|
140
|
+
"avoidBuiltinConflicts": true,
|
|
141
|
+
"suppressBuiltinConflictWarnings": true
|
|
142
|
+
}
|
|
128
143
|
}
|
|
129
144
|
```
|
|
130
145
|
|
|
131
146
|
See `config/config.example.json` for the same template.
|
|
132
147
|
|
|
148
|
+
#### Debug logging
|
|
149
|
+
|
|
150
|
+
Debug logging is disabled by default. Set `debug` to `true` to append debug events to `debug/debug.log` inside the extension directory:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"debug": true,
|
|
155
|
+
"shortcuts": {
|
|
156
|
+
"pasteImage": ["ctrl+alt+v"],
|
|
157
|
+
"avoidBuiltinConflicts": true,
|
|
158
|
+
"suppressBuiltinConflictWarnings": true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
When `debug` is `false` or omitted, no debug log file is opened or written.
|
|
164
|
+
|
|
165
|
+
#### Custom shortcuts
|
|
166
|
+
|
|
167
|
+
Set `shortcuts.pasteImage` to choose the exact shortcuts registered by `pi-image-tools`. The value can be a single shortcut string or an array of shortcut strings. Add multiple entries when you want several key combinations to run the same paste-image action:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"debug": false,
|
|
172
|
+
"shortcuts": {
|
|
173
|
+
"pasteImage": ["ctrl+alt+v", "alt+v", "ctrl+shift+v"],
|
|
174
|
+
"avoidBuiltinConflicts": true,
|
|
175
|
+
"suppressBuiltinConflictWarnings": true
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
A single shortcut is also valid:
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"debug": false,
|
|
185
|
+
"shortcuts": {
|
|
186
|
+
"pasteImage": "ctrl+alt+v",
|
|
187
|
+
"avoidBuiltinConflicts": true,
|
|
188
|
+
"suppressBuiltinConflictWarnings": true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Config changes are applied the next time the extension loads, so restart Pi or reload extensions after editing `config.json`.
|
|
194
|
+
|
|
195
|
+
Not every terminal can transmit every key combination to Pi. Triple-modifier shortcuts such as `ctrl+shift+alt+v` may be intercepted by the OS, terminal, shell, SSH, or tmux before Pi can see them. If a configured shortcut does not work, try a simpler shortcut such as `ctrl+alt+v`, `ctrl+shift+v`, `alt+p`, `f8`, or another function key.
|
|
196
|
+
|
|
197
|
+
Keep `shortcuts.avoidBuiltinConflicts` set to `true` to skip configured paste-image shortcuts that overlap any effective Pi built-in shortcut. Keep `shortcuts.suppressBuiltinConflictWarnings` set to `true` when your goal is specifically to remove Pi's startup conflict warning noise. Both options use the same safe mechanism: `pi-image-tools` does not register the overlapping shortcut, so Pi has nothing to warn about. For example, `ctrl+p` is skipped because Pi uses it for built-in model/session actions.
|
|
198
|
+
|
|
199
|
+
If both options are `false`, Pi handles conflicts itself. Non-reserved built-in conflicts may be taken over by `pi-image-tools`, but Pi can still print a startup warning. Reserved built-in shortcuts cannot be stolen through the extension shortcut API; Pi skips those registrations before `pi-image-tools` can handle them. To use a reserved Pi shortcut, rebind or disable the relevant built-in action in `~/.pi/agent/keybindings.json`, then restart Pi or reload extensions.
|
|
200
|
+
|
|
201
|
+
Use an empty array to disable the extension's paste-image shortcuts while keeping `/paste-image` available:
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"debug": false,
|
|
206
|
+
"shortcuts": {
|
|
207
|
+
"pasteImage": [],
|
|
208
|
+
"avoidBuiltinConflicts": true,
|
|
209
|
+
"suppressBuiltinConflictWarnings": true
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
If `shortcuts.pasteImage` is omitted, `pi-image-tools` uses non-conflicting defaults and automatically restores the previous primary shortcut when Pi's built-in `app.clipboard.pasteImage` binding is disabled or rebound.
|
|
215
|
+
|
|
133
216
|
## Default recent-image search locations
|
|
134
217
|
|
|
135
218
|
### Windows
|
|
@@ -200,16 +283,16 @@ pi-image-tools/
|
|
|
200
283
|
│ ├── index.ts # Extension bootstrap and message flow
|
|
201
284
|
│ ├── commands.ts # /paste-image command registration
|
|
202
285
|
│ ├── clipboard.ts # Cross-platform clipboard image reading
|
|
286
|
+
│ ├── config.ts # Runtime config loading and validation
|
|
287
|
+
│ ├── debug-logger.ts # File-based debug logging
|
|
203
288
|
│ ├── recent-images.ts # Recent image discovery and cache management
|
|
204
289
|
│ ├── image-preview.ts # Preview building and Sixel/native rendering
|
|
205
290
|
│ ├── inline-user-preview.ts # Inline preview patching for user messages
|
|
206
291
|
│ ├── keybindings.ts # Keyboard shortcut registration
|
|
207
292
|
│ ├── temp-file.ts # Temporary file management and cleanup
|
|
208
293
|
│ └── types.ts # Shared TypeScript types
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
└── asset/
|
|
212
|
-
└── pi-image-tools.png # README preview image
|
|
294
|
+
└── config/
|
|
295
|
+
└── config.example.json # Starter runtime config
|
|
213
296
|
```
|
|
214
297
|
|
|
215
298
|
## Troubleshooting
|
package/package.json
CHANGED
|
@@ -1,67 +1,66 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-image-tools",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Image attachment and rendering extension for Pi TUI",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./index.ts",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./index.ts"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"index.ts",
|
|
12
|
-
"src",
|
|
13
|
-
"config/config.example.json",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"pi
|
|
27
|
-
"pi",
|
|
28
|
-
"pi-
|
|
29
|
-
"pi-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"image",
|
|
33
|
-
"image-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"@mariozechner/pi-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-image-tools",
|
|
3
|
+
"version": "1.0.11",
|
|
4
|
+
"description": "Image attachment and rendering extension for Pi TUI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"src",
|
|
13
|
+
"config/config.example.json",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
|
|
20
|
+
"lint": "npm run build",
|
|
21
|
+
"test": "node --test",
|
|
22
|
+
"check": "npm run lint && npm run test"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"pi-package",
|
|
26
|
+
"pi",
|
|
27
|
+
"pi-extension",
|
|
28
|
+
"pi-coding-agent",
|
|
29
|
+
"pi-tui",
|
|
30
|
+
"coding-agent",
|
|
31
|
+
"image",
|
|
32
|
+
"image-attachment",
|
|
33
|
+
"image-preview",
|
|
34
|
+
"clipboard",
|
|
35
|
+
"preview",
|
|
36
|
+
"windows"
|
|
37
|
+
],
|
|
38
|
+
"author": "MasuRii",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/MasuRii/pi-image-tools.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/MasuRii/pi-image-tools#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/MasuRii/pi-image-tools/issues"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"pi": {
|
|
55
|
+
"extensions": [
|
|
56
|
+
"./index.ts"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@mariozechner/pi-coding-agent": "^0.70.2",
|
|
61
|
+
"@mariozechner/pi-tui": "^0.70.2"
|
|
62
|
+
},
|
|
63
|
+
"optionalDependencies": {
|
|
64
|
+
"@mariozechner/clipboard": "^0.3.3"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import type { KeyId } from "@mariozechner/pi-tui";
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
8
|
+
const EXTENSION_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
|
|
10
|
+
export interface ImageToolsShortcutConfig {
|
|
11
|
+
avoidBuiltinConflicts: boolean;
|
|
12
|
+
suppressBuiltinConflictWarnings: boolean;
|
|
13
|
+
pasteImage?: KeyId[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ImageToolsConfig {
|
|
17
|
+
debug: boolean;
|
|
18
|
+
shortcuts: ImageToolsShortcutConfig;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getExtensionRoot(): string {
|
|
26
|
+
return EXTENSION_ROOT;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getConfigPath(): string {
|
|
30
|
+
return join(getExtensionRoot(), CONFIG_FILE_NAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatConfigPath(path: string, property: string): string {
|
|
34
|
+
return `${path}${property.length > 0 ? ` property \"${property}\"` : ""}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseBoolean(value: unknown, property: string, path: string): boolean {
|
|
38
|
+
if (value === undefined) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof value !== "boolean") {
|
|
43
|
+
throw new Error(`Invalid pi-image-tools config at ${formatConfigPath(path, property)}: expected a boolean.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeShortcut(value: unknown, property: string, path: string): KeyId {
|
|
50
|
+
if (typeof value !== "string") {
|
|
51
|
+
throw new Error(`Invalid pi-image-tools config at ${formatConfigPath(path, property)}: expected a string shortcut.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const shortcut = value.trim();
|
|
55
|
+
if (shortcut.length === 0) {
|
|
56
|
+
throw new Error(`Invalid pi-image-tools config at ${formatConfigPath(path, property)}: shortcut cannot be empty.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return shortcut as KeyId;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseShortcutList(value: unknown, property: string, path: string): KeyId[] | undefined {
|
|
63
|
+
if (value === undefined) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rawShortcuts = Array.isArray(value) ? value : [value];
|
|
68
|
+
const shortcuts: KeyId[] = [];
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
|
|
71
|
+
for (const [index, rawShortcut] of rawShortcuts.entries()) {
|
|
72
|
+
const shortcut = normalizeShortcut(rawShortcut, `${property}[${index}]`, path);
|
|
73
|
+
const normalized = shortcut.toLowerCase();
|
|
74
|
+
if (seen.has(normalized)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
seen.add(normalized);
|
|
79
|
+
shortcuts.push(shortcut);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return shortcuts;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseShortcutConfig(value: unknown, path: string): ImageToolsShortcutConfig {
|
|
86
|
+
if (value === undefined) {
|
|
87
|
+
return {
|
|
88
|
+
avoidBuiltinConflicts: false,
|
|
89
|
+
suppressBuiltinConflictWarnings: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!isRecord(value)) {
|
|
94
|
+
throw new Error(`Invalid pi-image-tools config at ${formatConfigPath(path, "shortcuts")}: expected an object.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const avoidBuiltinConflicts = parseBoolean(
|
|
98
|
+
value.avoidBuiltinConflicts,
|
|
99
|
+
"shortcuts.avoidBuiltinConflicts",
|
|
100
|
+
path,
|
|
101
|
+
);
|
|
102
|
+
const suppressBuiltinConflictWarnings = parseBoolean(
|
|
103
|
+
value.suppressBuiltinConflictWarnings,
|
|
104
|
+
"shortcuts.suppressBuiltinConflictWarnings",
|
|
105
|
+
path,
|
|
106
|
+
);
|
|
107
|
+
const pasteImage = parseShortcutList(value.pasteImage, "shortcuts.pasteImage", path);
|
|
108
|
+
|
|
109
|
+
return pasteImage === undefined
|
|
110
|
+
? { avoidBuiltinConflicts, suppressBuiltinConflictWarnings }
|
|
111
|
+
: { avoidBuiltinConflicts, suppressBuiltinConflictWarnings, pasteImage };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseConfig(rawConfig: unknown, path: string): ImageToolsConfig {
|
|
115
|
+
if (!isRecord(rawConfig)) {
|
|
116
|
+
throw new Error(`Invalid pi-image-tools config at ${path}: expected a JSON object.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
debug: parseBoolean(rawConfig.debug, "debug", path),
|
|
121
|
+
shortcuts: parseShortcutConfig(rawConfig.shortcuts, path),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function loadImageToolsConfig(path = getConfigPath()): ImageToolsConfig {
|
|
126
|
+
if (!existsSync(path)) {
|
|
127
|
+
return {
|
|
128
|
+
debug: false,
|
|
129
|
+
shortcuts: {
|
|
130
|
+
avoidBuiltinConflicts: false,
|
|
131
|
+
suppressBuiltinConflictWarnings: false,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const rawConfig = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
138
|
+
return parseConfig(rawConfig, path);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof SyntaxError) {
|
|
141
|
+
throw new Error(`Invalid pi-image-tools config at ${path}: ${error.message}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { getExtensionRoot, type ImageToolsConfig } from "./config.js";
|
|
5
|
+
|
|
6
|
+
const DEBUG_DIRECTORY_NAME = "debug";
|
|
7
|
+
const DEBUG_LOG_FILE_NAME = "debug.log";
|
|
8
|
+
|
|
9
|
+
type DebugFields = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
function getErrorMessage(error: unknown): string {
|
|
12
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
13
|
+
return error.message;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return "Unknown error";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DebugLogger {
|
|
20
|
+
private readonly logPath: string | undefined;
|
|
21
|
+
|
|
22
|
+
private constructor(private readonly enabled: boolean) {
|
|
23
|
+
this.logPath = enabled ? join(getExtensionRoot(), DEBUG_DIRECTORY_NAME, DEBUG_LOG_FILE_NAME) : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static create(config: ImageToolsConfig): DebugLogger {
|
|
27
|
+
return new DebugLogger(config.debug);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
log(event: string, fields: DebugFields = {}): void {
|
|
31
|
+
if (!this.enabled || !this.logPath) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const debugDirectory = join(getExtensionRoot(), DEBUG_DIRECTORY_NAME);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(debugDirectory, { recursive: true });
|
|
39
|
+
appendFileSync(
|
|
40
|
+
this.logPath,
|
|
41
|
+
`${JSON.stringify({ timestamp: new Date().toISOString(), event, ...fields })}\n`,
|
|
42
|
+
"utf-8",
|
|
43
|
+
);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(`pi-image-tools debug logging failed at ${this.logPath}: ${getErrorMessage(error)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
import { readClipboardImage } from "./clipboard.js";
|
|
4
4
|
import { registerPasteImageCommand } from "./commands.js";
|
|
5
|
+
import { loadImageToolsConfig } from "./config.js";
|
|
6
|
+
import { DebugLogger } from "./debug-logger.js";
|
|
5
7
|
import {
|
|
6
8
|
IMAGE_PREVIEW_CUSTOM_TYPE,
|
|
7
9
|
buildPreviewItems,
|
|
@@ -144,8 +146,15 @@ function showRecentSelectionPreview(
|
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
149
|
+
const config = loadImageToolsConfig();
|
|
150
|
+
const logger = DebugLogger.create(config);
|
|
147
151
|
const pendingImages: PendingImage[] = [];
|
|
148
152
|
|
|
153
|
+
logger.log("extension.initialize", {
|
|
154
|
+
debug: config.debug,
|
|
155
|
+
pasteImageShortcutsConfigured: config.shortcuts.pasteImage !== undefined,
|
|
156
|
+
});
|
|
157
|
+
|
|
149
158
|
registerInlineUserImagePreview(pi);
|
|
150
159
|
registerImagePreviewDisplay(pi);
|
|
151
160
|
|
|
@@ -249,7 +258,7 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
249
258
|
};
|
|
250
259
|
});
|
|
251
260
|
|
|
252
|
-
registerImagePasteKeybindings(pi, pasteImageFromClipboard);
|
|
261
|
+
registerImagePasteKeybindings(pi, pasteImageFromClipboard, { config, logger });
|
|
253
262
|
registerPasteImageCommand(pi, {
|
|
254
263
|
fromClipboard: pasteImageFromClipboard,
|
|
255
264
|
fromRecent: pasteImageFromRecent,
|
package/src/keybindings.ts
CHANGED
|
@@ -1,9 +1,222 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { getAgentDir, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { TUI_KEYBINDINGS, type KeyId, type KeybindingsConfig } from "@mariozechner/pi-tui";
|
|
6
|
+
|
|
7
|
+
import { isRecord, type ImageToolsConfig } from "./config.js";
|
|
8
|
+
import type { DebugLogger } from "./debug-logger.js";
|
|
4
9
|
import type { PasteImageHandler } from "./types.js";
|
|
5
10
|
|
|
6
|
-
|
|
11
|
+
type KeybindingDefaults = Record<string, KeyId | KeyId[]>;
|
|
12
|
+
|
|
13
|
+
const LEGACY_KEYBINDING_NAME_MIGRATIONS: Record<string, string> = {
|
|
14
|
+
cursorUp: "tui.editor.cursorUp",
|
|
15
|
+
cursorDown: "tui.editor.cursorDown",
|
|
16
|
+
cursorLeft: "tui.editor.cursorLeft",
|
|
17
|
+
cursorRight: "tui.editor.cursorRight",
|
|
18
|
+
cursorWordLeft: "tui.editor.cursorWordLeft",
|
|
19
|
+
cursorWordRight: "tui.editor.cursorWordRight",
|
|
20
|
+
cursorLineStart: "tui.editor.cursorLineStart",
|
|
21
|
+
cursorLineEnd: "tui.editor.cursorLineEnd",
|
|
22
|
+
jumpForward: "tui.editor.jumpForward",
|
|
23
|
+
jumpBackward: "tui.editor.jumpBackward",
|
|
24
|
+
pageUp: "tui.editor.pageUp",
|
|
25
|
+
pageDown: "tui.editor.pageDown",
|
|
26
|
+
deleteCharBackward: "tui.editor.deleteCharBackward",
|
|
27
|
+
deleteCharForward: "tui.editor.deleteCharForward",
|
|
28
|
+
deleteWordBackward: "tui.editor.deleteWordBackward",
|
|
29
|
+
deleteWordForward: "tui.editor.deleteWordForward",
|
|
30
|
+
deleteToLineStart: "tui.editor.deleteToLineStart",
|
|
31
|
+
deleteToLineEnd: "tui.editor.deleteToLineEnd",
|
|
32
|
+
yank: "tui.editor.yank",
|
|
33
|
+
yankPop: "tui.editor.yankPop",
|
|
34
|
+
undo: "tui.editor.undo",
|
|
35
|
+
newLine: "tui.input.newLine",
|
|
36
|
+
submit: "tui.input.submit",
|
|
37
|
+
tab: "tui.input.tab",
|
|
38
|
+
copy: "tui.input.copy",
|
|
39
|
+
selectUp: "tui.select.up",
|
|
40
|
+
selectDown: "tui.select.down",
|
|
41
|
+
selectPageUp: "tui.select.pageUp",
|
|
42
|
+
selectPageDown: "tui.select.pageDown",
|
|
43
|
+
selectConfirm: "tui.select.confirm",
|
|
44
|
+
selectCancel: "tui.select.cancel",
|
|
45
|
+
interrupt: "app.interrupt",
|
|
46
|
+
clear: "app.clear",
|
|
47
|
+
exit: "app.exit",
|
|
48
|
+
suspend: "app.suspend",
|
|
49
|
+
cycleThinkingLevel: "app.thinking.cycle",
|
|
50
|
+
cycleModelForward: "app.model.cycleForward",
|
|
51
|
+
cycleModelBackward: "app.model.cycleBackward",
|
|
52
|
+
selectModel: "app.model.select",
|
|
53
|
+
expandTools: "app.tools.expand",
|
|
54
|
+
toggleThinking: "app.thinking.toggle",
|
|
55
|
+
toggleSessionNamedFilter: "app.session.toggleNamedFilter",
|
|
56
|
+
externalEditor: "app.editor.external",
|
|
57
|
+
followUp: "app.message.followUp",
|
|
58
|
+
dequeue: "app.message.dequeue",
|
|
59
|
+
pasteImage: "app.clipboard.pasteImage",
|
|
60
|
+
newSession: "app.session.new",
|
|
61
|
+
tree: "app.session.tree",
|
|
62
|
+
fork: "app.session.fork",
|
|
63
|
+
resume: "app.session.resume",
|
|
64
|
+
treeFoldOrUp: "app.tree.foldOrUp",
|
|
65
|
+
treeUnfoldOrDown: "app.tree.unfoldOrDown",
|
|
66
|
+
treeEditLabel: "app.tree.editLabel",
|
|
67
|
+
treeToggleLabelTimestamp: "app.tree.toggleLabelTimestamp",
|
|
68
|
+
toggleSessionPath: "app.session.togglePath",
|
|
69
|
+
toggleSessionSort: "app.session.toggleSort",
|
|
70
|
+
renameSession: "app.session.rename",
|
|
71
|
+
deleteSession: "app.session.delete",
|
|
72
|
+
deleteSessionNoninvasive: "app.session.deleteNoninvasive",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const APP_KEYBINDING_DEFAULTS: KeybindingDefaults = {
|
|
76
|
+
"app.interrupt": "escape",
|
|
77
|
+
"app.clear": "ctrl+c",
|
|
78
|
+
"app.exit": "ctrl+d",
|
|
79
|
+
"app.suspend": process.platform === "win32" ? [] : "ctrl+z",
|
|
80
|
+
"app.thinking.cycle": "shift+tab",
|
|
81
|
+
"app.model.cycleForward": "ctrl+p",
|
|
82
|
+
"app.model.cycleBackward": "shift+ctrl+p",
|
|
83
|
+
"app.model.select": "ctrl+l",
|
|
84
|
+
"app.tools.expand": "ctrl+o",
|
|
85
|
+
"app.thinking.toggle": "ctrl+t",
|
|
86
|
+
"app.session.toggleNamedFilter": "ctrl+n",
|
|
87
|
+
"app.editor.external": "ctrl+g",
|
|
88
|
+
"app.message.followUp": "alt+enter",
|
|
89
|
+
"app.message.dequeue": "alt+up",
|
|
90
|
+
"app.clipboard.pasteImage": process.platform === "win32" ? "alt+v" : "ctrl+v",
|
|
91
|
+
"app.session.new": [],
|
|
92
|
+
"app.session.tree": [],
|
|
93
|
+
"app.session.fork": [],
|
|
94
|
+
"app.session.resume": [],
|
|
95
|
+
"app.tree.foldOrUp": ["ctrl+left", "alt+left"],
|
|
96
|
+
"app.tree.unfoldOrDown": ["ctrl+right", "alt+right"],
|
|
97
|
+
"app.tree.editLabel": "shift+l",
|
|
98
|
+
"app.tree.toggleLabelTimestamp": "shift+t",
|
|
99
|
+
"app.session.togglePath": "ctrl+p",
|
|
100
|
+
"app.session.toggleSort": "ctrl+s",
|
|
101
|
+
"app.session.rename": "ctrl+r",
|
|
102
|
+
"app.session.delete": "ctrl+d",
|
|
103
|
+
"app.session.deleteNoninvasive": "ctrl+backspace",
|
|
104
|
+
"app.models.save": "ctrl+s",
|
|
105
|
+
"app.models.enableAll": "ctrl+a",
|
|
106
|
+
"app.models.clearAll": "ctrl+x",
|
|
107
|
+
"app.models.toggleProvider": "ctrl+p",
|
|
108
|
+
"app.models.reorderUp": "alt+up",
|
|
109
|
+
"app.models.reorderDown": "alt+down",
|
|
110
|
+
"app.tree.filter.default": "ctrl+d",
|
|
111
|
+
"app.tree.filter.noTools": "ctrl+t",
|
|
112
|
+
"app.tree.filter.userOnly": "ctrl+u",
|
|
113
|
+
"app.tree.filter.labeledOnly": "ctrl+l",
|
|
114
|
+
"app.tree.filter.all": "ctrl+a",
|
|
115
|
+
"app.tree.filter.cycleForward": "ctrl+o",
|
|
116
|
+
"app.tree.filter.cycleBackward": "shift+ctrl+o",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export interface RegisterImagePasteKeybindingsOptions {
|
|
120
|
+
config: ImageToolsConfig;
|
|
121
|
+
logger: DebugLogger;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toShortcutList(shortcuts: KeybindingsConfig[string]): KeyId[] {
|
|
125
|
+
if (shortcuts === undefined) {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return Array.isArray(shortcuts) ? shortcuts : [shortcuts];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeKeybinding(value: unknown): KeybindingsConfig[string] | undefined {
|
|
133
|
+
if (typeof value === "string") {
|
|
134
|
+
return value as KeyId;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
|
|
138
|
+
return value as KeyId[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizeShortcutKey(shortcut: KeyId): string {
|
|
145
|
+
return shortcut.toLowerCase();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readKeybindingsConfig(): Record<string, unknown> | undefined {
|
|
149
|
+
const keybindingsPath = join(getAgentDir(), "keybindings.json");
|
|
150
|
+
if (!existsSync(keybindingsPath)) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(readFileSync(keybindingsPath, "utf-8")) as unknown;
|
|
156
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function migrateKeybindingName(key: string): string {
|
|
163
|
+
return LEGACY_KEYBINDING_NAME_MIGRATIONS[key] ?? key;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeUserKeybindings(rawConfig: Record<string, unknown> | undefined): KeybindingsConfig {
|
|
167
|
+
if (!rawConfig) {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const userKeybindings: KeybindingsConfig = {};
|
|
172
|
+
for (const [key, value] of Object.entries(rawConfig)) {
|
|
173
|
+
const normalizedValue = normalizeKeybinding(value);
|
|
174
|
+
if (normalizedValue === undefined) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const migratedKey = migrateKeybindingName(key);
|
|
179
|
+
if (migratedKey !== key && Object.hasOwn(rawConfig, migratedKey)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
userKeybindings[migratedKey] = normalizedValue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return userKeybindings;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function getBuiltinKeybindingDefaults(): KeybindingDefaults {
|
|
190
|
+
const tuiDefaults: KeybindingDefaults = {};
|
|
191
|
+
for (const [keybinding, definition] of Object.entries(TUI_KEYBINDINGS)) {
|
|
192
|
+
tuiDefaults[keybinding] = definition.defaultKeys;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
...tuiDefaults,
|
|
197
|
+
...APP_KEYBINDING_DEFAULTS,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getConfiguredBuiltinShortcuts(): Set<string> {
|
|
202
|
+
const defaults = getBuiltinKeybindingDefaults();
|
|
203
|
+
const userKeybindings = normalizeUserKeybindings(readKeybindingsConfig());
|
|
204
|
+
const builtinShortcuts = new Set<string>();
|
|
205
|
+
|
|
206
|
+
for (const [keybinding, defaultShortcuts] of Object.entries(defaults)) {
|
|
207
|
+
const configuredShortcuts = Object.hasOwn(userKeybindings, keybinding)
|
|
208
|
+
? userKeybindings[keybinding]
|
|
209
|
+
: defaultShortcuts;
|
|
210
|
+
|
|
211
|
+
for (const shortcut of toShortcutList(configuredShortcuts)) {
|
|
212
|
+
builtinShortcuts.add(normalizeShortcutKey(shortcut));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return builtinShortcuts;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function getImagePasteShortcutCandidates(platform: NodeJS.Platform): KeyId[] {
|
|
7
220
|
if (platform === "win32") {
|
|
8
221
|
return ["alt+v", "ctrl+alt+v"];
|
|
9
222
|
}
|
|
@@ -11,8 +224,48 @@ function getImagePasteShortcuts(platform: NodeJS.Platform = process.platform): K
|
|
|
11
224
|
return ["ctrl+v", "alt+v", "ctrl+alt+v"];
|
|
12
225
|
}
|
|
13
226
|
|
|
14
|
-
|
|
15
|
-
|
|
227
|
+
function removeBuiltinConflicts(shortcuts: readonly KeyId[]): KeyId[] {
|
|
228
|
+
const builtinShortcuts = getConfiguredBuiltinShortcuts();
|
|
229
|
+
|
|
230
|
+
return shortcuts.filter((shortcut) => !builtinShortcuts.has(normalizeShortcutKey(shortcut)));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getImagePasteShortcuts(
|
|
234
|
+
config: ImageToolsConfig,
|
|
235
|
+
platform: NodeJS.Platform = process.platform,
|
|
236
|
+
): KeyId[] {
|
|
237
|
+
const shouldAvoidBuiltinConflicts =
|
|
238
|
+
config.shortcuts.avoidBuiltinConflicts || config.shortcuts.suppressBuiltinConflictWarnings;
|
|
239
|
+
|
|
240
|
+
if (config.shortcuts.pasteImage !== undefined) {
|
|
241
|
+
return shouldAvoidBuiltinConflicts
|
|
242
|
+
? removeBuiltinConflicts(config.shortcuts.pasteImage)
|
|
243
|
+
: config.shortcuts.pasteImage;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return removeBuiltinConflicts(getImagePasteShortcutCandidates(platform));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function registerImagePasteKeybindings(
|
|
250
|
+
pi: ExtensionAPI,
|
|
251
|
+
handler: PasteImageHandler,
|
|
252
|
+
options: RegisterImagePasteKeybindingsOptions,
|
|
253
|
+
): void {
|
|
254
|
+
const configuredShortcuts = options.config.shortcuts.pasteImage;
|
|
255
|
+
const shortcuts = getImagePasteShortcuts(options.config);
|
|
256
|
+
const skippedShortcuts = configuredShortcuts?.filter(
|
|
257
|
+
(shortcut) => !shortcuts.some((registeredShortcut) => normalizeShortcutKey(registeredShortcut) === normalizeShortcutKey(shortcut)),
|
|
258
|
+
) ?? [];
|
|
259
|
+
|
|
260
|
+
options.logger.log("keybindings.register", {
|
|
261
|
+
avoidBuiltinConflicts: options.config.shortcuts.avoidBuiltinConflicts,
|
|
262
|
+
suppressBuiltinConflictWarnings: options.config.shortcuts.suppressBuiltinConflictWarnings,
|
|
263
|
+
configured: configuredShortcuts !== undefined,
|
|
264
|
+
registeredShortcuts: shortcuts,
|
|
265
|
+
skippedShortcuts,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
for (const shortcut of shortcuts) {
|
|
16
269
|
pi.registerShortcut(shortcut, {
|
|
17
270
|
description: "Attach clipboard image to draft (send when ready)",
|
|
18
271
|
handler,
|
package/src/sixel-protocol.ts
CHANGED
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
const SIXEL_IMAGE_LINE_MARKER = "\x1b_Gm=0;\x1b\\";
|
|
2
|
-
const KITTY_IMAGE_LINE_MARKER = "\x1b_G";
|
|
3
|
-
const ITERM_IMAGE_LINE_MARKER = "\x1b]1337;File=";
|
|
4
|
-
const SIXEL_DCS_PREFIX = "\x1bP";
|
|
5
|
-
const STRING_TERMINATOR = "\x1b\\";
|
|
6
|
-
const MAX_IMAGE_ROWS = 80;
|
|
7
|
-
|
|
8
|
-
function sanitizeRows(rows: number): number {
|
|
9
|
-
return Math.max(1, Math.min(Math.trunc(rows), MAX_IMAGE_ROWS));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function normalizeSixelOutput(value: string): string {
|
|
13
|
-
return value.replace(/\r?\n/g, "").replace(/\s+$/g, "");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Ensure the PowerShell Sixel output is emitted as a complete DCS sequence.
|
|
18
|
-
* Some converters return only the sixel payload body; terminals need the
|
|
19
|
-
* enclosing ESC P ... ESC \\ wrapper to render it as an image.
|
|
20
|
-
*/
|
|
21
|
-
export function ensureCompleteSixelSequence(sequence: string): string {
|
|
22
|
-
let normalized = normalizeSixelOutput(sequence);
|
|
23
|
-
if (normalized.length === 0) {
|
|
24
|
-
return "";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (!normalized.startsWith(SIXEL_DCS_PREFIX)) {
|
|
28
|
-
normalized = `${SIXEL_DCS_PREFIX}${normalized.startsWith("q") ? normalized : `q${normalized}`}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (!normalized.endsWith(STRING_TERMINATOR)) {
|
|
32
|
-
normalized = `${normalized}${STRING_TERMINATOR}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return normalized;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function buildSixelRenderLines(sequence: string, rows: number): string[] {
|
|
39
|
-
const safeRows = sanitizeRows(rows);
|
|
40
|
-
const completeSequence = ensureCompleteSixelSequence(sequence);
|
|
41
|
-
if (completeSequence.length === 0) {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const lines = Array.from({ length: Math.max(0, safeRows - 1) }, () => "");
|
|
46
|
-
const moveUp = safeRows > 1 ? `\x1b[${safeRows - 1}A` : "";
|
|
47
|
-
return [...lines, `${SIXEL_IMAGE_LINE_MARKER}${moveUp}${completeSequence}`];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function isInlineImageProtocolLine(line: string): boolean {
|
|
51
|
-
return (
|
|
52
|
-
line.includes(SIXEL_IMAGE_LINE_MARKER) ||
|
|
53
|
-
line.includes(KITTY_IMAGE_LINE_MARKER) ||
|
|
54
|
-
line.includes(ITERM_IMAGE_LINE_MARKER) ||
|
|
55
|
-
line.includes(SIXEL_DCS_PREFIX)
|
|
56
|
-
);
|
|
57
|
-
}
|
|
1
|
+
const SIXEL_IMAGE_LINE_MARKER = "\x1b_Gm=0;\x1b\\";
|
|
2
|
+
const KITTY_IMAGE_LINE_MARKER = "\x1b_G";
|
|
3
|
+
const ITERM_IMAGE_LINE_MARKER = "\x1b]1337;File=";
|
|
4
|
+
const SIXEL_DCS_PREFIX = "\x1bP";
|
|
5
|
+
const STRING_TERMINATOR = "\x1b\\";
|
|
6
|
+
const MAX_IMAGE_ROWS = 80;
|
|
7
|
+
|
|
8
|
+
function sanitizeRows(rows: number): number {
|
|
9
|
+
return Math.max(1, Math.min(Math.trunc(rows), MAX_IMAGE_ROWS));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeSixelOutput(value: string): string {
|
|
13
|
+
return value.replace(/\r?\n/g, "").replace(/\s+$/g, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ensure the PowerShell Sixel output is emitted as a complete DCS sequence.
|
|
18
|
+
* Some converters return only the sixel payload body; terminals need the
|
|
19
|
+
* enclosing ESC P ... ESC \\ wrapper to render it as an image.
|
|
20
|
+
*/
|
|
21
|
+
export function ensureCompleteSixelSequence(sequence: string): string {
|
|
22
|
+
let normalized = normalizeSixelOutput(sequence);
|
|
23
|
+
if (normalized.length === 0) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!normalized.startsWith(SIXEL_DCS_PREFIX)) {
|
|
28
|
+
normalized = `${SIXEL_DCS_PREFIX}${normalized.startsWith("q") ? normalized : `q${normalized}`}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!normalized.endsWith(STRING_TERMINATOR)) {
|
|
32
|
+
normalized = `${normalized}${STRING_TERMINATOR}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildSixelRenderLines(sequence: string, rows: number): string[] {
|
|
39
|
+
const safeRows = sanitizeRows(rows);
|
|
40
|
+
const completeSequence = ensureCompleteSixelSequence(sequence);
|
|
41
|
+
if (completeSequence.length === 0) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const lines = Array.from({ length: Math.max(0, safeRows - 1) }, () => "");
|
|
46
|
+
const moveUp = safeRows > 1 ? `\x1b[${safeRows - 1}A` : "";
|
|
47
|
+
return [...lines, `${SIXEL_IMAGE_LINE_MARKER}${moveUp}${completeSequence}`];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isInlineImageProtocolLine(line: string): boolean {
|
|
51
|
+
return (
|
|
52
|
+
line.includes(SIXEL_IMAGE_LINE_MARKER) ||
|
|
53
|
+
line.includes(KITTY_IMAGE_LINE_MARKER) ||
|
|
54
|
+
line.includes(ITERM_IMAGE_LINE_MARKER) ||
|
|
55
|
+
line.includes(SIXEL_DCS_PREFIX)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -1,90 +1,90 @@
|
|
|
1
|
-
import { SettingsManager, getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
|
|
4
|
-
export const DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS = 60;
|
|
5
|
-
|
|
6
|
-
export interface TerminalImageWidthOptions {
|
|
7
|
-
cwd?: string;
|
|
8
|
-
agentDir?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface SettingsWithOptionalTerminalWidth {
|
|
12
|
-
terminal?: {
|
|
13
|
-
imageWidthCells?: unknown;
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
type SettingsManagerWithOptionalWidthGetter = SettingsManager & {
|
|
18
|
-
getImageWidthCells?: () => number;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
let activeTerminalSettingsCwd = process.cwd();
|
|
22
|
-
|
|
23
|
-
function normalizeDirectoryPath(value: string | undefined): string | undefined {
|
|
24
|
-
if (typeof value !== "string") {
|
|
25
|
-
return undefined;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const trimmed = value.trim();
|
|
29
|
-
if (!trimmed) {
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return resolve(trimmed);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function normalizeImageWidthCells(value: unknown): number {
|
|
37
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
38
|
-
return DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return Math.max(1, Math.floor(value));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function readRawImageWidthCells(settings: unknown): unknown {
|
|
45
|
-
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return (settings as SettingsWithOptionalTerminalWidth).terminal?.imageWidthCells;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function resolveSettingsManagerImageWidthCells(settingsManager: SettingsManager): unknown {
|
|
53
|
-
const settingsManagerWithOptionalWidthGetter =
|
|
54
|
-
settingsManager as SettingsManagerWithOptionalWidthGetter;
|
|
55
|
-
|
|
56
|
-
if (typeof settingsManagerWithOptionalWidthGetter.getImageWidthCells === "function") {
|
|
57
|
-
return settingsManagerWithOptionalWidthGetter.getImageWidthCells();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const projectWidth = readRawImageWidthCells(settingsManager.getProjectSettings());
|
|
61
|
-
if (projectWidth !== undefined) {
|
|
62
|
-
return projectWidth;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return readRawImageWidthCells(settingsManager.getGlobalSettings());
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function setActiveTerminalImageSettingsCwd(cwd: string | undefined): void {
|
|
69
|
-
const normalized = normalizeDirectoryPath(cwd);
|
|
70
|
-
if (normalized) {
|
|
71
|
-
activeTerminalSettingsCwd = normalized;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function resolveTerminalImageWidthCells(
|
|
76
|
-
options: TerminalImageWidthOptions = {},
|
|
77
|
-
): number {
|
|
78
|
-
const cwd = normalizeDirectoryPath(options.cwd) ?? activeTerminalSettingsCwd;
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const settingsManager = SettingsManager.create(
|
|
82
|
-
cwd,
|
|
83
|
-
normalizeDirectoryPath(options.agentDir) ?? getAgentDir(),
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
return normalizeImageWidthCells(resolveSettingsManagerImageWidthCells(settingsManager));
|
|
87
|
-
} catch {
|
|
88
|
-
return DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
1
|
+
import { SettingsManager, getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS = 60;
|
|
5
|
+
|
|
6
|
+
export interface TerminalImageWidthOptions {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
agentDir?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SettingsWithOptionalTerminalWidth {
|
|
12
|
+
terminal?: {
|
|
13
|
+
imageWidthCells?: unknown;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type SettingsManagerWithOptionalWidthGetter = SettingsManager & {
|
|
18
|
+
getImageWidthCells?: () => number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let activeTerminalSettingsCwd = process.cwd();
|
|
22
|
+
|
|
23
|
+
function normalizeDirectoryPath(value: string | undefined): string | undefined {
|
|
24
|
+
if (typeof value !== "string") {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trimmed = value.trim();
|
|
29
|
+
if (!trimmed) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return resolve(trimmed);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeImageWidthCells(value: unknown): number {
|
|
37
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
38
|
+
return DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return Math.max(1, Math.floor(value));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readRawImageWidthCells(settings: unknown): unknown {
|
|
45
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (settings as SettingsWithOptionalTerminalWidth).terminal?.imageWidthCells;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveSettingsManagerImageWidthCells(settingsManager: SettingsManager): unknown {
|
|
53
|
+
const settingsManagerWithOptionalWidthGetter =
|
|
54
|
+
settingsManager as SettingsManagerWithOptionalWidthGetter;
|
|
55
|
+
|
|
56
|
+
if (typeof settingsManagerWithOptionalWidthGetter.getImageWidthCells === "function") {
|
|
57
|
+
return settingsManagerWithOptionalWidthGetter.getImageWidthCells();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const projectWidth = readRawImageWidthCells(settingsManager.getProjectSettings());
|
|
61
|
+
if (projectWidth !== undefined) {
|
|
62
|
+
return projectWidth;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return readRawImageWidthCells(settingsManager.getGlobalSettings());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function setActiveTerminalImageSettingsCwd(cwd: string | undefined): void {
|
|
69
|
+
const normalized = normalizeDirectoryPath(cwd);
|
|
70
|
+
if (normalized) {
|
|
71
|
+
activeTerminalSettingsCwd = normalized;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveTerminalImageWidthCells(
|
|
76
|
+
options: TerminalImageWidthOptions = {},
|
|
77
|
+
): number {
|
|
78
|
+
const cwd = normalizeDirectoryPath(options.cwd) ?? activeTerminalSettingsCwd;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const settingsManager = SettingsManager.create(
|
|
82
|
+
cwd,
|
|
83
|
+
normalizeDirectoryPath(options.agentDir) ?? getAgentDir(),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return normalizeImageWidthCells(resolveSettingsManagerImageWidthCells(settingsManager));
|
|
87
|
+
} catch {
|
|
88
|
+
return DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/asset/pi-image-tools.png
DELETED
|
Binary file
|