pi-orca-images 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - README: banner, before/after screenshot and a demo GIF of the `[Image N]` labels.
6
+
7
+ ## 0.2.0
8
+
9
+ - 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.
10
+ - `/orca-images status` reports whether image labels are on.
11
+ - Fix: images in a session opened with `/resume`, `/new` or `/fork` showed only their first row; they are now redrawn in full.
12
+
3
13
  ## 0.1.0
4
14
 
5
15
  - Detect Orca from `ORCA_IMAGE_PROTOCOL` and enable Pi's inline images.
package/README.md CHANGED
@@ -1,8 +1,17 @@
1
- # pi-orca-images
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/JlMoreno00/pi-orca-images/main/docs/banner.png" alt="pi-orca-images: inline images for the Pi coding agent inside Orca's terminal" width="100%">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/pi-orca-images"><img src="https://img.shields.io/npm/v/pi-orca-images?color=8ab4f8&label=npm" alt="npm version"></a>
7
+ <a href="https://github.com/JlMoreno00/pi-orca-images/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/JlMoreno00/pi-orca-images/ci.yml?branch=main&label=ci" alt="CI"></a>
8
+ <a href="LICENSE"><img src="https://img.shields.io/npm/l/pi-orca-images?color=8b92a3" alt="MIT license"></a>
9
+ <a href="https://pi.dev/packages"><img src="https://img.shields.io/badge/pi-package-ec4899" alt="Pi package"></a>
10
+ </p>
2
11
 
3
12
  Inline images for the [Pi coding agent](https://pi.dev) inside [Orca](https://github.com/stablyai/orca)'s terminal.
4
13
 
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.
14
+ 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, and adds OpenCode-style `[Image 1]` labels for pasted images.
6
15
 
7
16
  ```bash
8
17
  pi install npm:pi-orca-images
@@ -10,6 +19,10 @@ pi install npm:pi-orca-images
10
19
 
11
20
  Restart Pi in an Orca terminal and run `/orca-images test`. You should see a color gradient.
12
21
 
22
+ <p align="center">
23
+ <img src="https://raw.githubusercontent.com/JlMoreno00/pi-orca-images/main/docs/before-after.png" alt="The same read of an image in Pi inside Orca: without the package only the tool card shows; with it, the image is drawn in full" width="860">
24
+ </p>
25
+
13
26
  ## What it fixes
14
27
 
15
28
  Orca renders inline images through xterm.js's image addon. Three things keep Pi's images from showing up there:
@@ -20,11 +33,21 @@ Orca renders inline images through xterm.js's image addon. Three things keep Pi'
20
33
  | 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
34
  | 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
35
 
36
+ ## Pasted image labels
37
+
38
+ <p align="center">
39
+ <img src="https://raw.githubusercontent.com/JlMoreno00/pi-orca-images/main/docs/image-labels.gif" alt="Typing a prompt in Pi, pasting two image paths that turn into [Image 1] and [Image 2] labels, deleting one label with a single backspace, and the sent message containing the real paths" width="760">
40
+ </p>
41
+
42
+ 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.
43
+
44
+ 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.
45
+
23
46
  ## Commands
24
47
 
25
48
  | Command | Effect |
26
49
  |---|---|
27
- | `/orca-images` or `/orca-images status` | Shows the detection decision, Pi's active image protocol, and whether the repaint fix is on |
50
+ | `/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 |
28
51
  | `/orca-images test` | Draws a test gradient (it is not sent to the model) |
29
52
  | `/orca-images redraw` | Re-sends every visible image right now |
30
53
 
@@ -34,7 +57,8 @@ It does nothing outside Orca. Your explicit choices always win:
34
57
 
35
58
  | Setting | Effect |
36
59
  |---|---|
37
- | `PI_ORCA_IMAGES=0` | Disables the package |
60
+ | `PI_ORCA_IMAGES=0` | Disables the Orca image fixes |
61
+ | `PI_IMAGE_LABELS=0` | Disables pasted image labels |
38
62
  | `PI_IMAGE_PROTOCOL=…` | Used as is; the package does not override it |
39
63
  | `terminal.images` in Pi settings | Applied by Pi on top of the package's choice |
40
64
  | `terminal.showImages: false` | Pi still hides images |
@@ -44,6 +68,7 @@ It does nothing outside Orca. Your explicit choices always win:
44
68
  - Orca with inline images (v1.4.229 or later exports `ORCA_IMAGE_PROTOCOL`) and *Settings → Terminal → Inline Images* on (the default).
45
69
  - 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.
46
70
  - Not inside tmux or screen: image escapes do not reach Orca through them.
71
+ - 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.
47
72
  - 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.
48
73
 
49
74
  ## Development
@@ -52,8 +77,11 @@ It does nothing outside Orca. Your explicit choices always win:
52
77
  npm install
53
78
  npm run check # typecheck + tests
54
79
  pi -e ./src/index.ts # try it without installing
80
+ python3 scripts/banner.py # regenerate docs/banner.png (Pillow, Inter, JetBrains Mono)
55
81
  ```
56
82
 
83
+ The screenshots in `docs/` are real captures of Pi 1.4 in Orca 1.4.229 on macOS.
84
+
57
85
  ## License
58
86
 
59
87
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orca-images",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Inline images for the Pi coding agent inside Orca's terminal",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/detect.ts CHANGED
@@ -42,3 +42,8 @@ export function decide(env: Env): Decision {
42
42
  }
43
43
  return { action: "apply", protocol, reason: `Orca detected (ORCA_IMAGE_PROTOCOL=${advertised})` };
44
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 CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, getCapabilities, Image, resetCapabilitiesCache, Text } from "@earendil-works/pi-tui";
3
3
  import { completeSubcommand, SUBCOMMANDS } from "./commands.ts";
4
- import { decide, type Decision, OWNED_ENV } from "./detect.ts";
4
+ import { decide, type Decision, labelsEnabled, OWNED_ENV } from "./detect.ts";
5
+ import { type EditorWatcher, watchEditors } from "./labels.ts";
5
6
  import { installShim, type Shim, type ShimTarget } from "./shim.ts";
6
7
  import { formatStatus } from "./status.ts";
7
8
  import { testImagePng } from "./test-image.ts";
@@ -28,20 +29,41 @@ function applyDecision(decision: Decision, env = process.env): void {
28
29
  export default function piOrcaImages(pi: ExtensionAPI) {
29
30
  const decision = decide(process.env);
30
31
  applyDecision(decision);
32
+ const labels = labelsEnabled(process.env);
31
33
  let shim: Shim | undefined;
34
+ let watcher: EditorWatcher | undefined;
32
35
 
33
36
  pi.on("session_start", async (_event, ctx) => {
34
- if (decision.action !== "apply" || decision.protocol !== "kitty" || !ctx.hasUI) return;
37
+ const repaint = decision.action === "apply" && decision.protocol === "kitty";
38
+ if (!ctx.hasUI || (!repaint && !labels)) return;
35
39
  // The widget factory is the only public hook that hands an extension the TUI instance.
36
40
  ctx.ui.setWidget(WIDGET, (tui) => {
37
- shim = installShim(tui as unknown as ShimTarget);
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
+ }
38
54
  return { render: () => [], invalidate() {} };
39
55
  });
40
56
  ctx.ui.setWidget(WIDGET, undefined);
41
57
  });
42
58
 
43
- pi.on("session_shutdown", async () => {
44
- shim?.uninstall();
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();
45
67
  shim = undefined;
46
68
  });
47
69
 
@@ -69,6 +91,7 @@ export default function piOrcaImages(pi: ExtensionAPI) {
69
91
  images: getCapabilities().images,
70
92
  shim,
71
93
  orcaVersion: process.env.ORCA_APP_VERSION ?? process.env.TERM_PROGRAM_VERSION,
94
+ labels: labels && watcher !== undefined,
72
95
  }),
73
96
  "info",
74
97
  );
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/status.ts CHANGED
@@ -6,9 +6,10 @@ export interface StatusInput {
6
6
  images: string | null | undefined;
7
7
  shim: Pick<Shim, "renderPatched" | "focusPatched" | "recoveries"> | undefined;
8
8
  orcaVersion: string | undefined;
9
+ labels: boolean;
9
10
  }
10
11
 
11
- export function formatStatus({ decision, images, shim, orcaVersion }: StatusInput): string {
12
+ export function formatStatus({ decision, images, shim, orcaVersion, labels }: StatusInput): string {
12
13
  const action = decision.action === "apply" ? `apply ${decision.protocol}` : decision.action;
13
14
  const repaint = shim
14
15
  ? `repaint fix: ${shim.renderPatched ? "on" : "unavailable"}, focus recovery: ${
@@ -20,6 +21,7 @@ export function formatStatus({ decision, images, shim, orcaVersion }: StatusInpu
20
21
  ` decision: ${action}: ${decision.reason}`,
21
22
  ` Pi image protocol: ${images ?? "none"}`,
22
23
  ` ${repaint}`,
24
+ ` image labels: ${labels ? "on" : "off (PI_IMAGE_LABELS)"}`,
23
25
  ` Orca: ${orcaVersion ?? "not detected"}`,
24
26
  ].join("\n");
25
27
  }