pi-orca-images 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/package.json +50 -4
- package/src/commands.ts +12 -0
- package/src/detect.ts +49 -0
- package/src/image-paths.ts +81 -0
- package/src/index.ts +108 -0
- package/src/labels.ts +220 -0
- package/src/shim.ts +116 -0
- package/src/status.ts +27 -0
- package/src/test-image.ts +46 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Show pasted image paths as bold, colored `[Image N]` labels in the prompt; they expand back to the path on send. `PI_IMAGE_LABELS=0` turns them off.
|
|
6
|
+
- `/orca-images status` reports whether image labels are on.
|
|
7
|
+
- Fix: images in a session opened with `/resume`, `/new` or `/fork` showed only their first row; they are now redrawn in full.
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
|
|
11
|
+
- Detect Orca from `ORCA_IMAGE_PROTOCOL` and enable Pi's inline images.
|
|
12
|
+
- Draw Kitty images in full inside Orca (draw-images-last render path).
|
|
13
|
+
- Restore images lost while a tab was hidden, on focus-in.
|
|
14
|
+
- `/orca-images status|test|redraw`.
|
|
15
|
+
- `PI_ORCA_IMAGES=0` opt-out; explicit `PI_IMAGE_PROTOCOL` always wins.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JlMoreno00
|
|
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,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-orca-images
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Inline images for the [Pi coding agent](https://pi.dev) inside [Orca](https://github.com/stablyai/orca)'s terminal.
|
|
4
|
+
|
|
5
|
+
Pi draws images natively (screenshots it reads, image tool results, previews), but inside Orca you get a blank gap or `Read image file [image/png]` and nothing else. This package fixes that with no configuration.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-orca-images
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Restart Pi in an Orca terminal and run `/orca-images test`. You should see a color gradient.
|
|
12
|
+
|
|
13
|
+
## What it fixes
|
|
14
|
+
|
|
15
|
+
Orca renders inline images through xterm.js's image addon. Three things keep Pi's images from showing up there:
|
|
16
|
+
|
|
17
|
+
| Problem | Cause | What this package does |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| No images at all | Pi does not recognize `TERM_PROGRAM=Orca` and ignores the `ORCA_IMAGE_PROTOCOL=kitty` hint that Orca exports | Sets `PI_IMAGE_PROTOCOL` from `ORCA_IMAGE_PROTOCOL` at startup |
|
|
20
|
+
| Only the first row of an image is drawn | The addon stores Kitty images per cell, so Pi's later line writes erase the rest | Turns on Pi's "draw images last" render path (the one Pi already uses for WezTerm), only while Pi renders; the variable never reaches commands Pi runs |
|
|
21
|
+
| Images vanish after switching tabs | When output arrives while a pane is hidden, Orca rebuilds the pane from a snapshot that has no images ([stablyai/orca#32714](https://github.com/stablyai/orca/issues/32714)) | On focus-in, re-sends every visible image a few times over the next 5 seconds, until Orca's restore has finished |
|
|
22
|
+
|
|
23
|
+
## Pasted image labels
|
|
24
|
+
|
|
25
|
+
Paste an image (or drop an image file) into Pi's prompt and the path is shown as a bold, colored `[Image 1]` label, as in OpenCode. Labels are numbered per message, move and delete as one unit, and become the real path again when you send the message, so the model sees exactly what it saw before.
|
|
26
|
+
|
|
27
|
+
It recognizes pastes made only of existing image files (`png`, `jpg`, `gif`, `webp`, `bmp`, `tiff`, `heic`, `avif`): plain paths, `~/` paths, quoted or backslash-escaped paths, and `file://` URLs. Text that mixes a path with other words is left alone. This works in every terminal, not only Orca, including Pi's own clipboard paste. Set `PI_IMAGE_LABELS=0` to turn it off.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
| Command | Effect |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `/orca-images` or `/orca-images status` | Shows the detection decision, Pi's active image protocol, and whether the repaint fix and image labels are on |
|
|
34
|
+
| `/orca-images test` | Draws a test gradient (it is not sent to the model) |
|
|
35
|
+
| `/orca-images redraw` | Re-sends every visible image right now |
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
It does nothing outside Orca. Your explicit choices always win:
|
|
40
|
+
|
|
41
|
+
| Setting | Effect |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `PI_ORCA_IMAGES=0` | Disables the Orca image fixes |
|
|
44
|
+
| `PI_IMAGE_LABELS=0` | Disables pasted image labels |
|
|
45
|
+
| `PI_IMAGE_PROTOCOL=…` | Used as is; the package does not override it |
|
|
46
|
+
| `terminal.images` in Pi settings | Applied by Pi on top of the package's choice |
|
|
47
|
+
| `terminal.showImages: false` | Pi still hides images |
|
|
48
|
+
|
|
49
|
+
## Requirements and limits
|
|
50
|
+
|
|
51
|
+
- Orca with inline images (v1.4.229 or later exports `ORCA_IMAGE_PROTOCOL`) and *Settings → Terminal → Inline Images* on (the default).
|
|
52
|
+
- Pi 1.4 in its default `fullscreen` TUI mode. In `regular` mode detection still works, but the repaint fix and tab recovery do not apply.
|
|
53
|
+
- Not inside tmux or screen: image escapes do not reach Orca through them.
|
|
54
|
+
- Image labels wrap internal methods of Pi's editor (`handlePaste`, `segment`, `expandPasteMarkers`, `render`). If a Pi release changes them, pasted paths simply stay as paths.
|
|
55
|
+
- The repaint fix wraps two internal methods of Pi's TUI. If a Pi release renames them, `/orca-images status` reports the fix as `unavailable` and images fall back to the first-row behavior; detection keeps working.
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install
|
|
61
|
+
npm run check # typecheck + tests
|
|
62
|
+
pi -e ./src/index.ts # try it without installing
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-orca-images",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Inline images for the Pi coding agent inside Orca's terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "JlMoreno00",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/JlMoreno00/pi-orca-images.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/JlMoreno00/pi-orca-images#readme",
|
|
13
|
+
"bugs": "https://github.com/JlMoreno00/pi-orca-images/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pi-package",
|
|
16
|
+
"pi",
|
|
17
|
+
"pi-coding-agent",
|
|
18
|
+
"orca",
|
|
19
|
+
"kitty-graphics",
|
|
20
|
+
"inline-images",
|
|
21
|
+
"terminal"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"CHANGELOG.md"
|
|
28
|
+
],
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./src/index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.18"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test test/*.test.ts",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"check": "npm run typecheck && npm test"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"@earendil-works/pi-tui": "*"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
48
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
49
|
+
"@types/node": "^24.0.0",
|
|
50
|
+
"typescript": "^5.9.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const SUBCOMMANDS = ["status", "test", "redraw"] as const;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Argument completions for /orca-images. A complete subcommand yields none, so the first
|
|
5
|
+
* Enter runs the command instead of accepting a suggestion identical to the input.
|
|
6
|
+
*/
|
|
7
|
+
export function completeSubcommand(prefix: string): Array<{ value: string; label: string }> | null {
|
|
8
|
+
const typed = prefix.trim();
|
|
9
|
+
if ((SUBCOMMANDS as readonly string[]).includes(typed)) return null;
|
|
10
|
+
const matches = SUBCOMMANDS.filter((value) => value.startsWith(typed)).map((value) => ({ value, label: value }));
|
|
11
|
+
return matches.length > 0 ? matches : null;
|
|
12
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type ImageProtocol = "kitty" | "iterm2";
|
|
2
|
+
|
|
3
|
+
export type Decision =
|
|
4
|
+
| { action: "apply"; protocol: ImageProtocol; reason: string }
|
|
5
|
+
| { action: "skip"; reason: string }
|
|
6
|
+
| { action: "clear-inherited"; reason: string };
|
|
7
|
+
|
|
8
|
+
export type Env = Record<string, string | undefined>;
|
|
9
|
+
|
|
10
|
+
/** Records the PI_IMAGE_PROTOCOL value this package set, so a later run can tell it from a user choice. */
|
|
11
|
+
export const OWNED_ENV = "PI_ORCA_IMAGES_OWNED";
|
|
12
|
+
|
|
13
|
+
const DISABLED = new Set(["0", "false", "off", "no"]);
|
|
14
|
+
|
|
15
|
+
/** Decide whether Pi should draw images, from the environment alone. */
|
|
16
|
+
export function decide(env: Env): Decision {
|
|
17
|
+
const owned = env[OWNED_ENV];
|
|
18
|
+
const current = env.PI_IMAGE_PROTOCOL;
|
|
19
|
+
const inherited = current !== undefined && current === owned;
|
|
20
|
+
const skip = (reason: string): Decision =>
|
|
21
|
+
inherited ? { action: "clear-inherited", reason } : { action: "skip", reason };
|
|
22
|
+
|
|
23
|
+
if (DISABLED.has((env.PI_ORCA_IMAGES ?? "").toLowerCase())) return skip("disabled by PI_ORCA_IMAGES");
|
|
24
|
+
if (current && !inherited) return { action: "skip", reason: `PI_IMAGE_PROTOCOL=${current} is set explicitly` };
|
|
25
|
+
|
|
26
|
+
const term = (env.TERM ?? "").toLowerCase();
|
|
27
|
+
if (env.TMUX || term.startsWith("tmux") || term.startsWith("screen")) {
|
|
28
|
+
return skip("inside tmux or screen; image escapes do not reach Orca");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const advertised = env.ORCA_IMAGE_PROTOCOL;
|
|
32
|
+
if (!advertised) {
|
|
33
|
+
if ((env.TERM_PROGRAM ?? "").toLowerCase() === "orca") {
|
|
34
|
+
return skip("Orca does not advertise inline images (ORCA_IMAGE_PROTOCOL is unset); update Orca");
|
|
35
|
+
}
|
|
36
|
+
return skip("not running inside Orca");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const protocol = advertised.toLowerCase();
|
|
40
|
+
if (protocol !== "kitty" && protocol !== "iterm2") {
|
|
41
|
+
return skip(`Orca advertises ORCA_IMAGE_PROTOCOL=${advertised}, which Pi cannot draw`);
|
|
42
|
+
}
|
|
43
|
+
return { action: "apply", protocol, reason: `Orca detected (ORCA_IMAGE_PROTOCOL=${advertised})` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pasted-image labels work in any terminal; PI_IMAGE_LABELS=0 turns them off. */
|
|
47
|
+
export function labelsEnabled(env: Env): boolean {
|
|
48
|
+
return !DISABLED.has((env.PI_IMAGE_LABELS ?? "").toLowerCase());
|
|
49
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif|avif)$/i;
|
|
6
|
+
|
|
7
|
+
export interface ParsedImagePaths {
|
|
8
|
+
leading: string;
|
|
9
|
+
trailing: string;
|
|
10
|
+
paths: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isExistingFile(path: string): boolean {
|
|
14
|
+
try {
|
|
15
|
+
return statSync(path).isFile();
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Split on unquoted, unescaped whitespace; quotes and backslash escapes are removed. */
|
|
22
|
+
function tokenize(text: string): string[] {
|
|
23
|
+
const tokens: string[] = [];
|
|
24
|
+
let current = "";
|
|
25
|
+
let quote: string | undefined;
|
|
26
|
+
let inToken = false;
|
|
27
|
+
for (let i = 0; i < text.length; i++) {
|
|
28
|
+
const ch = text[i]!;
|
|
29
|
+
if (quote) {
|
|
30
|
+
if (ch === quote) quote = undefined;
|
|
31
|
+
else current += ch;
|
|
32
|
+
} else if (ch === "\\" && i + 1 < text.length) {
|
|
33
|
+
current += text[++i];
|
|
34
|
+
inToken = true;
|
|
35
|
+
} else if (ch === "'" || ch === '"') {
|
|
36
|
+
quote = ch;
|
|
37
|
+
inToken = true;
|
|
38
|
+
} else if (/\s/.test(ch)) {
|
|
39
|
+
if (inToken) tokens.push(current);
|
|
40
|
+
current = "";
|
|
41
|
+
inToken = false;
|
|
42
|
+
} else {
|
|
43
|
+
current += ch;
|
|
44
|
+
inToken = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (inToken) tokens.push(current);
|
|
48
|
+
return tokens;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toPath(token: string): string | undefined {
|
|
52
|
+
if (token.startsWith("file://")) {
|
|
53
|
+
try {
|
|
54
|
+
return fileURLToPath(token);
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (token === "~" || token.startsWith("~/")) return homedir() + token.slice(1);
|
|
60
|
+
return token;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Recognize text that consists only of image file paths, as pasted or dropped into a terminal.
|
|
65
|
+
* Returns null when any token is not an existing image file, so mixed text is never rewritten.
|
|
66
|
+
*/
|
|
67
|
+
export function parseImagePaths(
|
|
68
|
+
text: string,
|
|
69
|
+
isImageFile: (path: string) => boolean = isExistingFile,
|
|
70
|
+
): ParsedImagePaths | null {
|
|
71
|
+
const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(text);
|
|
72
|
+
const body = match?.[2] ?? "";
|
|
73
|
+
if (!body) return null;
|
|
74
|
+
const paths: string[] = [];
|
|
75
|
+
for (const token of tokenize(body)) {
|
|
76
|
+
const path = toPath(token);
|
|
77
|
+
if (!path || !IMAGE_EXTENSION.test(path) || !isImageFile(path)) return null;
|
|
78
|
+
paths.push(path);
|
|
79
|
+
}
|
|
80
|
+
return paths.length > 0 ? { leading: match?.[1] ?? "", trailing: match?.[3] ?? "", paths } : null;
|
|
81
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, getCapabilities, Image, resetCapabilitiesCache, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { completeSubcommand, SUBCOMMANDS } from "./commands.ts";
|
|
4
|
+
import { decide, type Decision, labelsEnabled, OWNED_ENV } from "./detect.ts";
|
|
5
|
+
import { type EditorWatcher, watchEditors } from "./labels.ts";
|
|
6
|
+
import { installShim, type Shim, type ShimTarget } from "./shim.ts";
|
|
7
|
+
import { formatStatus } from "./status.ts";
|
|
8
|
+
import { testImagePng } from "./test-image.ts";
|
|
9
|
+
|
|
10
|
+
const COMMAND = "orca-images";
|
|
11
|
+
const TEST_ENTRY = "pi-orca-images:test";
|
|
12
|
+
const WIDGET = "pi-orca-images:probe";
|
|
13
|
+
const USAGE = `Usage: /${COMMAND} [${SUBCOMMANDS.join("|")}]`;
|
|
14
|
+
|
|
15
|
+
/** Apply the decision to the process environment, before Pi reads its capabilities. */
|
|
16
|
+
function applyDecision(decision: Decision, env = process.env): void {
|
|
17
|
+
if (decision.action === "apply") {
|
|
18
|
+
env.PI_IMAGE_PROTOCOL = decision.protocol;
|
|
19
|
+
env[OWNED_ENV] = decision.protocol;
|
|
20
|
+
} else if (decision.action === "clear-inherited") {
|
|
21
|
+
delete env.PI_IMAGE_PROTOCOL;
|
|
22
|
+
delete env[OWNED_ENV];
|
|
23
|
+
} else {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
resetCapabilitiesCache();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function piOrcaImages(pi: ExtensionAPI) {
|
|
30
|
+
const decision = decide(process.env);
|
|
31
|
+
applyDecision(decision);
|
|
32
|
+
const labels = labelsEnabled(process.env);
|
|
33
|
+
let shim: Shim | undefined;
|
|
34
|
+
let watcher: EditorWatcher | undefined;
|
|
35
|
+
|
|
36
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
37
|
+
const repaint = decision.action === "apply" && decision.protocol === "kitty";
|
|
38
|
+
if (!ctx.hasUI || (!repaint && !labels)) return;
|
|
39
|
+
// The widget factory is the only public hook that hands an extension the TUI instance.
|
|
40
|
+
ctx.ui.setWidget(WIDGET, (tui) => {
|
|
41
|
+
if (repaint) {
|
|
42
|
+
shim = installShim(tui as unknown as ShimTarget);
|
|
43
|
+
// Resend whatever Pi drew before the fix was in place (first startup render, or a
|
|
44
|
+
// session switch on a Pi that calls this factory late). Deferred: Pi may call the
|
|
45
|
+
// factory while it renders.
|
|
46
|
+
const installed = shim;
|
|
47
|
+
setTimeout(() => installed.recover(), 0);
|
|
48
|
+
}
|
|
49
|
+
if (labels) {
|
|
50
|
+
watcher = watchEditors(tui as unknown as ShimTarget, {
|
|
51
|
+
style: (label) => ctx.ui.theme.fg("accent", ctx.ui.theme.bold(label)),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { render: () => [], invalidate() {} };
|
|
55
|
+
});
|
|
56
|
+
ctx.ui.setWidget(WIDGET, undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
pi.on("session_shutdown", async (event) => {
|
|
60
|
+
// The watcher styles labels through this session's ctx, which goes stale on replacement.
|
|
61
|
+
watcher?.uninstall();
|
|
62
|
+
watcher = undefined;
|
|
63
|
+
// On /resume, /new, /fork and /reload Pi draws the next transcript before session_start,
|
|
64
|
+
// so the repaint fix must stay in place; the next session reuses it (installShim is
|
|
65
|
+
// idempotent per TUI). Only quitting removes it.
|
|
66
|
+
if (event.reason === "quit") shim?.uninstall();
|
|
67
|
+
shim = undefined;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
pi.registerEntryRenderer<{ width: number; height: number }>(TEST_ENTRY, (entry, _options, theme) => {
|
|
71
|
+
const { width = 240, height = 120 } = entry.data ?? {};
|
|
72
|
+
const box = new Container();
|
|
73
|
+
box.addChild(new Text(theme.fg("muted", `pi-orca-images test image (${width}x${height})`), 0, 0));
|
|
74
|
+
box.addChild(
|
|
75
|
+
new Image(testImagePng(width, height), "image/png", { fallbackColor: (s: string) => theme.fg("warning", s) }, {
|
|
76
|
+
maxWidthCells: 30,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
return box;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.registerCommand(COMMAND, {
|
|
83
|
+
description: "Inline images in Orca: status, test, or redraw",
|
|
84
|
+
getArgumentCompletions: completeSubcommand,
|
|
85
|
+
handler: async (args, ctx) => {
|
|
86
|
+
const sub = (args ?? "").trim() || "status";
|
|
87
|
+
if (sub === "status") {
|
|
88
|
+
ctx.ui.notify(
|
|
89
|
+
formatStatus({
|
|
90
|
+
decision,
|
|
91
|
+
images: getCapabilities().images,
|
|
92
|
+
shim,
|
|
93
|
+
orcaVersion: process.env.ORCA_APP_VERSION ?? process.env.TERM_PROGRAM_VERSION,
|
|
94
|
+
labels: labels && watcher !== undefined,
|
|
95
|
+
}),
|
|
96
|
+
"info",
|
|
97
|
+
);
|
|
98
|
+
} else if (sub === "test") {
|
|
99
|
+
pi.appendEntry(TEST_ENTRY, { width: 240, height: 120 });
|
|
100
|
+
} else if (sub === "redraw") {
|
|
101
|
+
if (shim) shim.recover();
|
|
102
|
+
else ctx.ui.notify("pi-orca-images is not active in this terminal; nothing to redraw.", "warning");
|
|
103
|
+
} else {
|
|
104
|
+
ctx.ui.notify(USAGE, "warning");
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
package/src/labels.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { isExistingFile, parseImagePaths } from "./image-paths.ts";
|
|
2
|
+
|
|
3
|
+
/** The parts of pi-tui's Editor this module wraps. */
|
|
4
|
+
export interface LabelTarget {
|
|
5
|
+
handlePaste(text: string): void;
|
|
6
|
+
insertTextAtCursor(text: string): void;
|
|
7
|
+
segment(text: string, mode: "grapheme" | "word"): Iterable<SegmentLike>;
|
|
8
|
+
expandPasteMarkers(text: string): string;
|
|
9
|
+
submitValue(): void;
|
|
10
|
+
render(width: number): string[];
|
|
11
|
+
getText(): string;
|
|
12
|
+
getCursor?(): { line: number; col: number };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface SegmentLike {
|
|
16
|
+
segment: string;
|
|
17
|
+
index: number;
|
|
18
|
+
input?: string;
|
|
19
|
+
isWordLike?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LabelOptions {
|
|
23
|
+
/** Styles a label for display, e.g. bold accent color. */
|
|
24
|
+
style: (label: string) => string;
|
|
25
|
+
isImageFile?: (path: string) => boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ImageLabels {
|
|
29
|
+
readonly count: number;
|
|
30
|
+
uninstall(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LABELS = Symbol.for("pi-orca-images.labels");
|
|
34
|
+
const LABEL = /\[Image (\d+)\]/g;
|
|
35
|
+
|
|
36
|
+
const labelText = (id: number) => `[Image ${id}]`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Show pasted image paths as atomic `[Image N]` labels in Pi's editor.
|
|
40
|
+
*
|
|
41
|
+
* The label is what the user sees and edits; the path is what Pi submits, so the model and
|
|
42
|
+
* the prompt history behave exactly as if the path had been pasted.
|
|
43
|
+
*/
|
|
44
|
+
export function installImageLabels(editor: LabelTarget, options: LabelOptions): ImageLabels {
|
|
45
|
+
const host = editor as LabelTarget & { [LABELS]?: ImageLabels };
|
|
46
|
+
const existing = host[LABELS];
|
|
47
|
+
if (existing) return existing;
|
|
48
|
+
|
|
49
|
+
const isImageFile = options.isImageFile ?? isExistingFile;
|
|
50
|
+
const original = {
|
|
51
|
+
handlePaste: editor.handlePaste,
|
|
52
|
+
insertTextAtCursor: editor.insertTextAtCursor,
|
|
53
|
+
segment: editor.segment,
|
|
54
|
+
expandPasteMarkers: editor.expandPasteMarkers,
|
|
55
|
+
submitValue: editor.submitValue,
|
|
56
|
+
render: editor.render,
|
|
57
|
+
};
|
|
58
|
+
const ownKeys = Object.keys(original).filter((key) => Object.hasOwn(editor, key));
|
|
59
|
+
const paths = new Map<number, string>();
|
|
60
|
+
let next = 1;
|
|
61
|
+
|
|
62
|
+
const known = (text: string) => [...text.matchAll(LABEL)].filter((m) => paths.has(Number(m[1])));
|
|
63
|
+
|
|
64
|
+
/** Returns the labeled replacement for text made only of image paths, or undefined. */
|
|
65
|
+
const labelFor = (text: string, spaceBefore: boolean, spaceAfter: boolean): string | undefined => {
|
|
66
|
+
const parsed = parseImagePaths(text, isImageFile);
|
|
67
|
+
if (!parsed) return undefined;
|
|
68
|
+
if (known(editor.getText()).length === 0) {
|
|
69
|
+
paths.clear();
|
|
70
|
+
next = 1;
|
|
71
|
+
}
|
|
72
|
+
const labels = parsed.paths.map((path) => {
|
|
73
|
+
const id = next++;
|
|
74
|
+
paths.set(id, path);
|
|
75
|
+
return labelText(id);
|
|
76
|
+
});
|
|
77
|
+
const leading = parsed.leading || (spaceBefore ? " " : "");
|
|
78
|
+
const trailing = parsed.trailing || (spaceAfter ? " " : "");
|
|
79
|
+
return `${leading}${labels.join(" ")}${trailing}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const neighbors = () => {
|
|
83
|
+
const cursor = editor.getCursor?.();
|
|
84
|
+
if (!cursor) return { before: "", after: "" };
|
|
85
|
+
const line = editor.getText().split("\n")[cursor.line] ?? "";
|
|
86
|
+
return { before: line[cursor.col - 1] ?? "", after: line[cursor.col] ?? "" };
|
|
87
|
+
};
|
|
88
|
+
const needsSpace = (ch: string) => ch !== "" && !/\s/.test(ch);
|
|
89
|
+
|
|
90
|
+
editor.handlePaste = function (text: string) {
|
|
91
|
+
const { before, after } = neighbors();
|
|
92
|
+
const labeled = labelFor(text, needsSpace(before), after === "" || needsSpace(after));
|
|
93
|
+
if (labeled === undefined) return original.handlePaste.call(this, text);
|
|
94
|
+
return original.insertTextAtCursor.call(this, labeled);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
editor.insertTextAtCursor = function (text: string) {
|
|
98
|
+
const labeled = labelFor(text, false, false);
|
|
99
|
+
return original.insertTextAtCursor.call(this, labeled ?? text);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
editor.segment = function (text: string, mode: "grapheme" | "word") {
|
|
103
|
+
const base = original.segment.call(this, text, mode);
|
|
104
|
+
const spans = known(text).map((m) => ({ start: m.index, end: m.index + m[0].length }));
|
|
105
|
+
if (spans.length === 0) return base;
|
|
106
|
+
const merged: SegmentLike[] = [];
|
|
107
|
+
let span = 0;
|
|
108
|
+
for (const seg of base) {
|
|
109
|
+
while (span < spans.length && spans[span]!.end <= seg.index) span++;
|
|
110
|
+
const current = spans[span];
|
|
111
|
+
if (current && seg.index >= current.start && seg.index < current.end) {
|
|
112
|
+
if (seg.index === current.start) {
|
|
113
|
+
merged.push({ ...seg, segment: text.slice(current.start, current.end), isWordLike: true });
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
merged.push(seg);
|
|
118
|
+
}
|
|
119
|
+
return merged;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
editor.expandPasteMarkers = function (text: string) {
|
|
123
|
+
const expanded = text.replace(LABEL, (label, id: string) => paths.get(Number(id)) ?? label);
|
|
124
|
+
return original.expandPasteMarkers.call(this, expanded);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
editor.submitValue = function () {
|
|
128
|
+
try {
|
|
129
|
+
return original.submitValue.call(this);
|
|
130
|
+
} finally {
|
|
131
|
+
paths.clear();
|
|
132
|
+
next = 1;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
editor.render = function (width: number) {
|
|
137
|
+
const lines = original.render.call(this, width);
|
|
138
|
+
if (paths.size === 0) return lines;
|
|
139
|
+
return lines.map((line) =>
|
|
140
|
+
line.replace(LABEL, (label, id: string) => (paths.has(Number(id)) ? options.style(label) : label)),
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const labels: ImageLabels = {
|
|
145
|
+
get count() {
|
|
146
|
+
return paths.size;
|
|
147
|
+
},
|
|
148
|
+
uninstall() {
|
|
149
|
+
const target = editor as unknown as Record<string, unknown>;
|
|
150
|
+
for (const [key, value] of Object.entries(original)) {
|
|
151
|
+
// Restore earlier own wrappers; otherwise fall back to the prototype method.
|
|
152
|
+
if (ownKeys.includes(key)) target[key] = value;
|
|
153
|
+
else delete target[key];
|
|
154
|
+
}
|
|
155
|
+
delete host[LABELS];
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
host[LABELS] = labels;
|
|
159
|
+
return labels;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Find pi-tui editors in a component tree (TUI → containers → editor). */
|
|
163
|
+
export function findEditors(root: unknown): LabelTarget[] {
|
|
164
|
+
const found: LabelTarget[] = [];
|
|
165
|
+
const seen = new Set<unknown>();
|
|
166
|
+
const visit = (node: unknown) => {
|
|
167
|
+
if (!node || typeof node !== "object" || seen.has(node)) return;
|
|
168
|
+
seen.add(node);
|
|
169
|
+
const candidate = node as Partial<LabelTarget> & { pastes?: unknown; children?: unknown };
|
|
170
|
+
if (
|
|
171
|
+
typeof candidate.handlePaste === "function" &&
|
|
172
|
+
typeof candidate.segment === "function" &&
|
|
173
|
+
typeof candidate.expandPasteMarkers === "function" &&
|
|
174
|
+
candidate.pastes instanceof Map
|
|
175
|
+
) {
|
|
176
|
+
found.push(candidate as LabelTarget);
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(candidate.children)) for (const child of candidate.children) visit(child);
|
|
179
|
+
};
|
|
180
|
+
visit(root);
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface EditorWatcher {
|
|
185
|
+
readonly editors: number;
|
|
186
|
+
uninstall(): void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Label every editor in the TUI tree, checked before each render. Pi and extensions can swap the
|
|
191
|
+
* editor at any time (custom editors, settings changes), so a one-time lookup is not enough.
|
|
192
|
+
*/
|
|
193
|
+
export function watchEditors(
|
|
194
|
+
tui: { doRender?: (...args: unknown[]) => unknown },
|
|
195
|
+
options: LabelOptions,
|
|
196
|
+
): EditorWatcher {
|
|
197
|
+
const installed = new Map<LabelTarget, ImageLabels>();
|
|
198
|
+
const hadOwn = Object.hasOwn(tui, "doRender");
|
|
199
|
+
const originalRender = tui.doRender;
|
|
200
|
+
if (originalRender) {
|
|
201
|
+
tui.doRender = function (this: unknown, ...args: unknown[]) {
|
|
202
|
+
for (const editor of findEditors(tui)) {
|
|
203
|
+
if (!installed.has(editor)) installed.set(editor, installImageLabels(editor, options));
|
|
204
|
+
}
|
|
205
|
+
return originalRender.apply(this, args);
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
get editors() {
|
|
210
|
+
return installed.size;
|
|
211
|
+
},
|
|
212
|
+
uninstall() {
|
|
213
|
+
for (const labels of installed.values()) labels.uninstall();
|
|
214
|
+
installed.clear();
|
|
215
|
+
if (!originalRender) return;
|
|
216
|
+
if (hadOwn) tui.doRender = originalRender;
|
|
217
|
+
else delete tui.doRender;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
package/src/shim.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Focus-in report; Pi enables focus reporting (DECSET 1004) in fullscreen mode. */
|
|
2
|
+
export const FOCUS_IN = "\x1b[I";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Orca rebuilds a pane from its snapshot about 1.5-3 s after the tab is shown again,
|
|
6
|
+
* which wipes every inline image. Repainting at several points outlasts that restore.
|
|
7
|
+
*/
|
|
8
|
+
export const RECOVERY_DELAYS_MS = [300, 1500, 3000, 5000];
|
|
9
|
+
|
|
10
|
+
/** The parts of Pi's TUI the shim touches. Every member except requestRender is optional. */
|
|
11
|
+
export interface ShimTarget {
|
|
12
|
+
doRender?: (...args: unknown[]) => unknown;
|
|
13
|
+
handleViewportInput?: (data: string) => unknown;
|
|
14
|
+
uploadedKittyImages?: { clear(): void };
|
|
15
|
+
requestRender(force?: boolean): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ShimOptions {
|
|
19
|
+
env?: Record<string, string | undefined>;
|
|
20
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
21
|
+
clearTimer?: (handle: unknown) => void;
|
|
22
|
+
delays?: readonly number[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Shim {
|
|
26
|
+
readonly renderPatched: boolean;
|
|
27
|
+
readonly focusPatched: boolean;
|
|
28
|
+
readonly recoveries: number;
|
|
29
|
+
recover(): void;
|
|
30
|
+
uninstall(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SHIM = Symbol.for("pi-orca-images.shim");
|
|
34
|
+
const WEZTERM_MARKER = "pi-orca-images";
|
|
35
|
+
|
|
36
|
+
const defaultSetTimer = (fn: () => void, ms: number) => {
|
|
37
|
+
const handle = setTimeout(fn, ms);
|
|
38
|
+
handle.unref?.();
|
|
39
|
+
return handle;
|
|
40
|
+
};
|
|
41
|
+
const defaultClearTimer = (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Patch one Pi TUI instance for Orca.
|
|
45
|
+
*
|
|
46
|
+
* Render: xterm's image addon stores Kitty images per cell, so Pi's later line writes erase
|
|
47
|
+
* every row but the first. Pi already draws images last for WezTerm, which has the same
|
|
48
|
+
* behavior; the shim enables that path only during the synchronous render, so the variable
|
|
49
|
+
* never reaches child processes.
|
|
50
|
+
*
|
|
51
|
+
* Focus: when the tab is shown again, the shim clears Pi's upload cache and forces a full
|
|
52
|
+
* render, so images Orca dropped while hidden are sent again.
|
|
53
|
+
*/
|
|
54
|
+
export function installShim(tui: ShimTarget, options: ShimOptions = {}): Shim {
|
|
55
|
+
const host = tui as ShimTarget & { [SHIM]?: Shim };
|
|
56
|
+
const existing = host[SHIM];
|
|
57
|
+
if (existing) return existing;
|
|
58
|
+
|
|
59
|
+
const env = options.env ?? process.env;
|
|
60
|
+
const setTimer = options.setTimer ?? defaultSetTimer;
|
|
61
|
+
const clearTimer = options.clearTimer ?? defaultClearTimer;
|
|
62
|
+
const delays = options.delays ?? RECOVERY_DELAYS_MS;
|
|
63
|
+
const originalRender = tui.doRender;
|
|
64
|
+
const originalInput = tui.handleViewportInput;
|
|
65
|
+
let timers: unknown[] = [];
|
|
66
|
+
let recoveries = 0;
|
|
67
|
+
|
|
68
|
+
const cancel = () => {
|
|
69
|
+
for (const timer of timers) clearTimer(timer);
|
|
70
|
+
timers = [];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const shim: Shim = {
|
|
74
|
+
renderPatched: typeof originalRender === "function",
|
|
75
|
+
focusPatched: typeof originalInput === "function",
|
|
76
|
+
get recoveries() {
|
|
77
|
+
return recoveries;
|
|
78
|
+
},
|
|
79
|
+
recover() {
|
|
80
|
+
recoveries++;
|
|
81
|
+
tui.uploadedKittyImages?.clear();
|
|
82
|
+
tui.requestRender(true);
|
|
83
|
+
},
|
|
84
|
+
uninstall() {
|
|
85
|
+
cancel();
|
|
86
|
+
if (shim.renderPatched) tui.doRender = originalRender;
|
|
87
|
+
if (shim.focusPatched) tui.handleViewportInput = originalInput;
|
|
88
|
+
delete host[SHIM];
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (originalRender) {
|
|
93
|
+
tui.doRender = function (this: unknown, ...args: unknown[]) {
|
|
94
|
+
if (env.WEZTERM_PANE !== undefined) return originalRender.apply(this, args);
|
|
95
|
+
env.WEZTERM_PANE = WEZTERM_MARKER;
|
|
96
|
+
try {
|
|
97
|
+
return originalRender.apply(this, args);
|
|
98
|
+
} finally {
|
|
99
|
+
delete env.WEZTERM_PANE;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (originalInput) {
|
|
105
|
+
tui.handleViewportInput = function (this: unknown, data: string) {
|
|
106
|
+
if (data === FOCUS_IN) {
|
|
107
|
+
cancel();
|
|
108
|
+
timers = delays.map((ms) => setTimer(() => shim.recover(), ms));
|
|
109
|
+
}
|
|
110
|
+
return originalInput.call(this, data);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
host[SHIM] = shim;
|
|
115
|
+
return shim;
|
|
116
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Decision } from "./detect.ts";
|
|
2
|
+
import type { Shim } from "./shim.ts";
|
|
3
|
+
|
|
4
|
+
export interface StatusInput {
|
|
5
|
+
decision: Decision;
|
|
6
|
+
images: string | null | undefined;
|
|
7
|
+
shim: Pick<Shim, "renderPatched" | "focusPatched" | "recoveries"> | undefined;
|
|
8
|
+
orcaVersion: string | undefined;
|
|
9
|
+
labels: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatStatus({ decision, images, shim, orcaVersion, labels }: StatusInput): string {
|
|
13
|
+
const action = decision.action === "apply" ? `apply ${decision.protocol}` : decision.action;
|
|
14
|
+
const repaint = shim
|
|
15
|
+
? `repaint fix: ${shim.renderPatched ? "on" : "unavailable"}, focus recovery: ${
|
|
16
|
+
shim.focusPatched ? `on (recoveries: ${shim.recoveries})` : "unavailable"
|
|
17
|
+
}`
|
|
18
|
+
: "repaint fix: off";
|
|
19
|
+
return [
|
|
20
|
+
"pi-orca-images",
|
|
21
|
+
` decision: ${action}: ${decision.reason}`,
|
|
22
|
+
` Pi image protocol: ${images ?? "none"}`,
|
|
23
|
+
` ${repaint}`,
|
|
24
|
+
` image labels: ${labels ? "on" : "off (PI_IMAGE_LABELS)"}`,
|
|
25
|
+
` Orca: ${orcaVersion ?? "not detected"}`,
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { deflateSync } from "node:zlib";
|
|
2
|
+
|
|
3
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
4
|
+
let c = n;
|
|
5
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
6
|
+
return c >>> 0;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function crc32(data: Buffer): number {
|
|
10
|
+
let c = 0xffffffff;
|
|
11
|
+
for (const byte of data) c = CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8);
|
|
12
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function chunk(type: string, data: Buffer): Buffer {
|
|
16
|
+
const length = Buffer.alloc(4);
|
|
17
|
+
length.writeUInt32BE(data.length);
|
|
18
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
19
|
+
const crc = Buffer.alloc(4);
|
|
20
|
+
crc.writeUInt32BE(crc32(body));
|
|
21
|
+
return Buffer.concat([length, body, crc]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A diagonal color gradient as a base64 PNG; it needs no file and shows full-height rendering at a glance. */
|
|
25
|
+
export function testImagePng(width = 240, height = 120): string {
|
|
26
|
+
const header = Buffer.alloc(13);
|
|
27
|
+
header.writeUInt32BE(width, 0);
|
|
28
|
+
header.writeUInt32BE(height, 4);
|
|
29
|
+
header.set([8, 2, 0, 0, 0], 8);
|
|
30
|
+
const rowLength = 1 + width * 3;
|
|
31
|
+
const raw = Buffer.alloc(rowLength * height);
|
|
32
|
+
for (let y = 0; y < height; y++) {
|
|
33
|
+
for (let x = 0; x < width; x++) {
|
|
34
|
+
const offset = y * rowLength + 1 + x * 3;
|
|
35
|
+
raw[offset] = Math.round((255 * x) / Math.max(1, width - 1));
|
|
36
|
+
raw[offset + 1] = Math.round((255 * y) / Math.max(1, height - 1));
|
|
37
|
+
raw[offset + 2] = 200;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return Buffer.concat([
|
|
41
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
42
|
+
chunk("IHDR", header),
|
|
43
|
+
chunk("IDAT", deflateSync(raw)),
|
|
44
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
45
|
+
]).toString("base64");
|
|
46
|
+
}
|