@a-t-h-i/bot-lobby 0.6.13 → 0.6.15

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.
Files changed (71) hide show
  1. package/README.md +70 -79
  2. package/package.json +4 -2
  3. package/prompts/master.md +3 -2
  4. package/prompts/qa.md +33 -11
  5. package/prompts/reviewer.md +5 -2
  6. package/prompts/worker.md +5 -2
  7. package/src/ask/relay.ts +1 -2
  8. package/src/ask/tool.ts +4 -6
  9. package/src/ask/types.ts +16 -0
  10. package/src/ask/web.ts +63 -0
  11. package/src/index.ts +7 -1
  12. package/src/lobby/ask.ts +4 -5
  13. package/src/lobby/blocks.ts +57 -0
  14. package/src/lobby/feed.ts +1 -1
  15. package/src/lobby/host.ts +178 -0
  16. package/src/lobby/keys.ts +10 -41
  17. package/src/lobby/prompt-hub.ts +30 -77
  18. package/src/lobby/prompts.ts +8 -12
  19. package/src/lobby/runtime.ts +49 -310
  20. package/src/lobby/service.ts +54 -134
  21. package/src/lobby/task-rows.ts +28 -0
  22. package/src/pi/commands.ts +14 -20
  23. package/src/pi/model-settings.ts +112 -0
  24. package/src/pi/start-task.ts +1 -1
  25. package/src/pi/tools.ts +6 -7
  26. package/src/pi/ui.ts +0 -9
  27. package/src/schemas/configuration.ts +9 -33
  28. package/src/state/attachments.ts +82 -0
  29. package/src/webui/api/index.ts +17 -15
  30. package/src/webui/api/lobby.ts +4 -3
  31. package/src/webui/api/planner.ts +6 -4
  32. package/src/webui/api/quickfix.ts +4 -3
  33. package/src/webui/api/sessions.ts +9 -7
  34. package/src/webui/api/settings.ts +20 -8
  35. package/src/webui/api/status.ts +4 -15
  36. package/src/webui/api/tasks.ts +11 -15
  37. package/src/webui/command.ts +35 -19
  38. package/src/webui/dev/fake-service.ts +1 -2
  39. package/src/webui/dev/fixtures.ts +2 -5
  40. package/src/webui/events.ts +1 -1
  41. package/src/webui/protocol.ts +22 -11
  42. package/src/webui/server.ts +42 -1
  43. package/src/webui/uploads.ts +68 -0
  44. package/webui/dist/assets/index-B1PnCyl2.css +2 -0
  45. package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
  46. package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  47. package/webui/dist/build.json +142 -110
  48. package/webui/dist/index.html +4 -3
  49. package/src/ask/dialog.ts +0 -193
  50. package/src/ask/image.ts +0 -202
  51. package/src/ask/png.ts +0 -179
  52. package/src/ask/state.ts +0 -175
  53. package/src/ask/view.ts +0 -163
  54. package/src/lobby/layout.ts +0 -496
  55. package/src/lobby/markdown.ts +0 -121
  56. package/src/lobby/mini.ts +0 -167
  57. package/src/lobby/tabs/excalidraw.ts +0 -95
  58. package/src/lobby/tabs/git.ts +0 -162
  59. package/src/lobby/tabs/home.ts +0 -544
  60. package/src/lobby/tabs/issues.ts +0 -73
  61. package/src/lobby/tabs/knowledge.ts +0 -135
  62. package/src/lobby/tabs/metrics.ts +0 -300
  63. package/src/lobby/tabs/plan.ts +0 -262
  64. package/src/lobby/tabs/quickfix.ts +0 -136
  65. package/src/lobby/tabs/tasks.ts +0 -412
  66. package/src/lobby/theme.ts +0 -30
  67. package/src/lobby/view.ts +0 -3018
  68. package/src/pi/settings-ui.ts +0 -708
  69. package/src/width.ts +0 -102
  70. package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
  71. package/webui/dist/assets/index-E6MPcbdC.js +0 -80
package/src/ask/dialog.ts DELETED
@@ -1,193 +0,0 @@
1
- /**
2
- * Putting questions to the user. In pi's terminal the questionnaire opens as
3
- * an overlay (over the lobby too) with Markdown throughout and each option's
4
- * preview beside it. Where pi cannot draw one — RPC mode, which background
5
- * sessions and editor hosts run in — the same questions go through pi's own
6
- * select and input dialogs, which those hosts do forward.
7
- */
8
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import { getKeybindings, Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
10
- import type { LobbyTheme } from "../lobby/layout.ts";
11
- import { lobbyTheme } from "../lobby/theme.ts";
12
- import { initialState, putAway, step, type AskKey, type AskState } from "./state.ts";
13
- import { renderAsk, type AskFrame } from "./view.ts";
14
- import { loadImages } from "./image.ts";
15
- import { isAbsolute, resolve } from "node:path";
16
- import type { AskAnswer, AskQuestion, AskResult } from "./types.ts";
17
-
18
- /**
19
- * Puts up to `MAX_QUESTIONS` questions to the user and returns what they
20
- * chose. `from` names who asks when it is not this session's model (an agent
21
- * whose questions the oracle relays).
22
- */
23
- export type Asker = (questions: readonly AskQuestion[], ctx: ExtensionContext, signal?: AbortSignal, from?: string) => Promise<AskResult>;
24
-
25
- /** Share of the terminal the overlay may take. */
26
- const OVERLAY_HEIGHT = 0.9;
27
-
28
- /** Shift+Enter, Ctrl+J and the sequences terminals send for them: a new line, as in the lobby's prompt. */
29
- function isNewline(data: string): boolean {
30
- return getKeybindings().matches(data, "tui.input.newLine") || data === "\n" || data === "\x1b\r" || data === "\x1b[13;2~";
31
- }
32
-
33
- /** Text the terminal pasted arrives wrapped in bracketed-paste markers. */
34
- const PASTE = /^\x1b\[200~([\s\S]*)\x1b\[201~$/;
35
-
36
- /** A key press as the questionnaire reads it; undefined for keys it ignores. */
37
- export function readKey(data: string): AskKey | undefined {
38
- const paste = PASTE.exec(data);
39
- if (paste) {
40
- const text = [...paste[1]!].filter((char) => (char >= " " && char !== "\x7f") || char === "\n" || char === "\r" || char === "\t").join("");
41
- return text ? { type: "text", value: text } : undefined;
42
- }
43
- if (isNewline(data)) return { type: "newline" };
44
- if (matchesKey(data, Key.up)) return { type: "up" };
45
- if (matchesKey(data, Key.down)) return { type: "down" };
46
- if (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab"))) return { type: "left" };
47
- if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) return { type: "right" };
48
- if (matchesKey(data, Key.enter)) return { type: "enter" };
49
- if (matchesKey(data, Key.escape)) return { type: "escape" };
50
- if (matchesKey(data, Key.backspace)) return { type: "backspace" };
51
- if (data === " ") return { type: "space" };
52
- if (/^[1-9]$/.test(data)) return { type: "digit", value: Number(data) };
53
- // Typed text: anything printable (control sequences are dropped).
54
- const text = [...data].filter((char) => char >= " " && char !== "\x7f").join("");
55
- if (text && !data.startsWith("\x1b")) return { type: "text", value: text };
56
- return undefined;
57
- }
58
-
59
- /** The questionnaire as a pi component: keys step its state, each frame draws it. */
60
- export class AskDialog implements Component {
61
- private state: AskState;
62
- private readonly tui: TUI;
63
- private readonly theme: LobbyTheme;
64
- private readonly done: (result: AskResult) => void;
65
- private readonly frame: AskFrame;
66
-
67
- constructor(tui: TUI, theme: LobbyTheme, questions: readonly AskQuestion[], done: (result: AskResult) => void, frame: AskFrame = {}) {
68
- this.tui = tui;
69
- this.theme = theme;
70
- this.state = initialState(questions);
71
- this.done = done;
72
- this.frame = frame;
73
- }
74
-
75
- handleInput(data: string): void {
76
- const key = readKey(data);
77
- if (!key) return;
78
- this.state = step(this.state, key);
79
- if (this.state.result) this.done(this.state.result);
80
- else this.tui.requestRender();
81
- }
82
-
83
- /** Put the questions away from outside (the turn was aborted). */
84
- cancel(): void {
85
- if (this.state.result) return;
86
- this.state = putAway(this.state);
87
- this.done(this.state.result!);
88
- }
89
-
90
- render(width: number): string[] {
91
- return renderAsk(this.state, width, Math.max(8, Math.floor(this.tui.terminal.rows * OVERLAY_HEIGHT)), this.theme, this.frame);
92
- }
93
-
94
- invalidate(): void {}
95
- }
96
-
97
- /**
98
- * The questions in pi's terminal, or through its plain dialogs where it
99
- * cannot draw the questionnaire. Without any UI nobody can answer: the
100
- * result says the questions were put away.
101
- */
102
- export const askUser: Asker = (questions, ctx, signal, from) => {
103
- // A model may call the tool several times in one turn; pi runs them in parallel, and overlays opened together hide each other. One at a time.
104
- const next = asking.then(() => (signal?.aborted ? { answers: [], cancelled: true } : askNow(questions, ctx, signal, from)));
105
- asking = next.then(() => undefined, () => undefined);
106
- return next;
107
- };
108
-
109
- /** Tail of the questions waiting their turn. */
110
- let asking: Promise<void> = Promise.resolve();
111
-
112
- const askNow: Asker = async (questions, ctx, signal, from) => {
113
- if (!ctx.hasUI || questions.length === 0) return { answers: [], cancelled: true };
114
- if ((ctx as { mode?: string }).mode === "rpc") return dialogAsker(questions, ctx, signal, from);
115
- // Option images are read before the questionnaire opens, so drawing it never waits on the disk.
116
- const images = await loadImages(questions.flatMap((question) => question.options.map((option) => (option.image?.trim() ? imagePath(option.image, ctx.cwd) : ""))));
117
- const shown = questions.map((question) => ({ ...question, options: question.options.map((option) => (option.image?.trim() ? { ...option, image: imagePath(option.image, ctx.cwd) } : option)) }));
118
- let dialog: AskDialog | undefined;
119
- const onAbort = () => dialog?.cancel();
120
- signal?.addEventListener("abort", onAbort, { once: true });
121
- try {
122
- const result = await ctx.ui.custom<AskResult>((tui, theme, _keys, done) => {
123
- dialog = new AskDialog(tui, lobbyTheme(theme), shown, done, { ...(from ? { from } : {}), ...(images.size > 0 ? { images } : {}) });
124
- if (signal?.aborted) queueMicrotask(onAbort);
125
- return dialog;
126
- }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "90%", anchor: "center", margin: 1 } });
127
- // A host that cannot draw custom components resolves at once with nothing.
128
- return result ?? dialogAsker(questions, ctx, signal, from);
129
- } finally {
130
- signal?.removeEventListener("abort", onAbort);
131
- }
132
- };
133
-
134
- function imagePath(path: string, cwd: string): string {
135
- const trimmed = path.trim();
136
- return isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
137
- }
138
-
139
- const TYPE_ANSWER = "Type an answer…";
140
- const SKIP = "Skip";
141
- const DONE = "Done";
142
- /** Longest description or preview folded into a plain dialog's title. */
143
- const MAX_FOLDED = 600;
144
-
145
- /** A question as one plain dialog's title: the question, then each option's description and preview. */
146
- function dialogTitle(question: AskQuestion, index: number, total: number, from?: string): string {
147
- const details = question.options
148
- .map((option) => [
149
- option.description?.trim() ? `${option.label}: ${option.description.trim()}` : "",
150
- option.preview?.trim() ? `--- ${option.label} ---\n${option.preview.trim().slice(0, MAX_FOLDED)}` : "",
151
- option.image?.trim() ? `(${option.label}: see the image ${option.image.trim()})` : "",
152
- ].filter(Boolean).join("\n"))
153
- .filter(Boolean);
154
- return [`${from ? `${from} asks · ` : ""}${question.header} · ${index + 1}/${total}`, question.question, ...details].join("\n\n");
155
- }
156
-
157
- /** The same questions through pi's select and editor dialogs: pick, type an answer (Shift+Enter for a new line), or skip; esc stops. */
158
- export const dialogAsker: Asker = async (questions, ctx, _signal, from) => {
159
- const answers: AskAnswer[] = [];
160
- for (const [index, question] of questions.entries()) {
161
- const title = dialogTitle(question, index, questions.length, from);
162
- if (question.multiSelect) {
163
- const selected: string[] = [];
164
- for (;;) {
165
- const left = question.options.map((option) => option.label).filter((label) => !selected.includes(label));
166
- const choice = await ctx.ui.select(`${title}${selected.length > 0 ? `\n\nPicked: ${selected.join(", ")}` : ""}`, [...left, TYPE_ANSWER, selected.length > 0 ? DONE : SKIP]);
167
- if (choice === undefined) return { answers, cancelled: true };
168
- if (choice === DONE || choice === SKIP) break;
169
- if (choice === TYPE_ANSWER) {
170
- const typed = await ctx.ui.editor(question.question, "");
171
- if (typed === undefined) return { answers, cancelled: true };
172
- if (typed.trim()) selected.push(typed.trim());
173
- break;
174
- }
175
- selected.push(choice);
176
- if (left.length === 1) break;
177
- }
178
- if (selected.length > 0) answers.push({ questionIndex: index, question: question.question, kind: "multi", answer: selected.join(", "), selected });
179
- continue;
180
- }
181
- const choice = await ctx.ui.select(title, [...question.options.map((option) => option.label), TYPE_ANSWER, SKIP]);
182
- if (choice === undefined) return { answers, cancelled: true };
183
- if (choice === SKIP) continue;
184
- if (choice === TYPE_ANSWER) {
185
- const typed = await ctx.ui.editor(question.question, "");
186
- if (typed === undefined) return { answers, cancelled: true };
187
- if (typed.trim()) answers.push({ questionIndex: index, question: question.question, kind: "custom", answer: typed.trim() });
188
- continue;
189
- }
190
- answers.push({ questionIndex: index, question: question.question, kind: "option", answer: choice });
191
- }
192
- return { answers, cancelled: false };
193
- };
package/src/ask/image.ts DELETED
@@ -1,202 +0,0 @@
1
- /**
2
- * Images in the questionnaire: a screenshot or rendered mockup an option
3
- * carries, drawn in its preview. Terminals that speak the Kitty graphics
4
- * protocol (Kitty, Ghostty, WezTerm) get the image itself; everywhere else a
5
- * PNG is drawn with coloured half-blocks, which any colour terminal shows and
6
- * which sit in a box like text. Other formats there get a line saying what
7
- * and where the file is. `BOT_LOBBY_IMAGES=blocks` always draws blocks;
8
- * `off` never draws images.
9
- */
10
- import { readFile, stat } from "node:fs/promises";
11
- import { basename } from "node:path";
12
- import { getCapabilities, getImageDimensions, renderImage, type ImageDimensions } from "@earendil-works/pi-tui";
13
- import { paint, type LobbyTheme } from "../lobby/layout.ts";
14
- import { decodePng, type Pixels } from "./png.ts";
15
-
16
- /** Larger files are not read. */
17
- export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
18
-
19
- export type ImageMime = "image/png" | "image/jpeg" | "image/gif" | "image/webp";
20
-
21
- export interface LoadedImage {
22
- path: string;
23
- mime: ImageMime;
24
- base64: string;
25
- dimensions: ImageDimensions;
26
- /** Decoded on first use, for the half-block drawing (PNG only). */
27
- pixels?: Pixels | string;
28
- /** Kitty reuses one id per image so a redraw replaces it. */
29
- kittyId?: number;
30
- /** Half-block drawings already made, by size: every key press redraws the questionnaire. */
31
- blocks?: Map<string, string[]>;
32
- }
33
-
34
- /** An image option whose file could not be read, and why. */
35
- export interface MissingImage {
36
- path: string;
37
- error: string;
38
- }
39
-
40
- export type ImagePreview = LoadedImage | MissingImage;
41
-
42
- const EXTENSIONS = /\.(png|jpe?g|gif|webp)$/i;
43
-
44
- export function isImagePath(path: string): boolean {
45
- return EXTENSIONS.test(path.trim());
46
- }
47
-
48
- function sniff(bytes: Uint8Array): ImageMime | undefined {
49
- if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
50
- if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
51
- if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif";
52
- if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return "image/webp";
53
- return undefined;
54
- }
55
-
56
- /** Read an option's image file: a PNG, JPEG, GIF or WebP of at most 10 MB. */
57
- export async function loadImage(path: string): Promise<ImagePreview> {
58
- try {
59
- const info = await stat(path);
60
- if (!info.isFile()) return { path, error: "not a file" };
61
- if (info.size > MAX_IMAGE_BYTES) return { path, error: `too large to show (${Math.round(info.size / 1024 / 1024)} MB)` };
62
- const bytes = new Uint8Array(await readFile(path));
63
- const mime = sniff(bytes);
64
- if (!mime) return { path, error: "not a PNG, JPEG, GIF or WebP image" };
65
- const base64 = Buffer.from(bytes).toString("base64");
66
- const dimensions = getImageDimensions(base64, mime) ?? { widthPx: 800, heightPx: 600 };
67
- return { path, mime, base64, dimensions };
68
- } catch (error) {
69
- const code = (error as { code?: string }).code;
70
- return { path, error: code === "ENOENT" ? "no such file" : error instanceof Error ? error.message : String(error) };
71
- }
72
- }
73
-
74
- /** Every option image the questions carry, read once before the questionnaire opens. */
75
- export async function loadImages(paths: readonly string[]): Promise<Map<string, ImagePreview>> {
76
- const unique = [...new Set(paths.filter(Boolean))];
77
- const loaded = await Promise.all(unique.map((path) => loadImage(path)));
78
- return new Map(unique.map((path, index) => [path, loaded[index]!]));
79
- }
80
-
81
- type Mode = "native" | "blocks" | "off";
82
-
83
- function mode(): Mode {
84
- const chosen = (process.env.BOT_LOBBY_IMAGES ?? "").trim().toLowerCase();
85
- if (chosen === "off" || chosen === "blocks") return chosen;
86
- return getCapabilities().images === "kitty" ? "native" : "blocks";
87
- }
88
-
89
- function pixelsOf(image: LoadedImage): Pixels | string {
90
- if (image.pixels === undefined) {
91
- if (image.mime !== "image/png") image.pixels = "only PNG images can be drawn in this terminal";
92
- else {
93
- try {
94
- image.pixels = decodePng(Buffer.from(image.base64, "base64"));
95
- } catch (error) {
96
- image.pixels = error instanceof Error ? error.message : String(error);
97
- }
98
- }
99
- }
100
- return image.pixels;
101
- }
102
-
103
- /** The cell grid an image fits in: at most `columns` × `rows`, its aspect kept (a cell is about twice as tall as wide). */
104
- function fitCells(dimensions: ImageDimensions, columns: number, rows: number): { columns: number; rows: number } {
105
- const aspect = dimensions.heightPx / Math.max(1, dimensions.widthPx);
106
- let width = Math.max(1, columns);
107
- let height = Math.max(1, Math.round((width * aspect) / 2));
108
- if (height > rows) {
109
- height = Math.max(1, rows);
110
- width = Math.max(1, Math.min(columns, Math.round((height * 2) / aspect)));
111
- }
112
- return { columns: width, rows: height };
113
- }
114
-
115
- /** Average the pixels of one block of the image, over the box's dark background where they are see-through. */
116
- function averageColor(pixels: Pixels, x0: number, y0: number, x1: number, y1: number): [number, number, number] {
117
- let r = 0;
118
- let g = 0;
119
- let b = 0;
120
- let count = 0;
121
- const backdrop = 24;
122
- for (let y = y0; y < Math.max(y1, y0 + 1); y += 1) {
123
- for (let x = x0; x < Math.max(x1, x0 + 1); x += 1) {
124
- const at = (Math.min(y, pixels.height - 1) * pixels.width + Math.min(x, pixels.width - 1)) * 4;
125
- const alpha = pixels.data[at + 3]! / 255;
126
- r += pixels.data[at]! * alpha + backdrop * (1 - alpha);
127
- g += pixels.data[at + 1]! * alpha + backdrop * (1 - alpha);
128
- b += pixels.data[at + 2]! * alpha + backdrop * (1 - alpha);
129
- count += 1;
130
- }
131
- }
132
- return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
133
- }
134
-
135
- /** Colour escape for a cell's foreground or background: 24-bit where the terminal has it, else the nearest of the 256. */
136
- function colour(rgb: [number, number, number], background: boolean, trueColor: boolean): string {
137
- const [r, g, b] = rgb;
138
- if (trueColor) return `\x1b[${background ? 48 : 38};2;${r};${g};${b}m`;
139
- const level = (value: number) => (value < 48 ? 0 : value < 115 ? 1 : Math.min(5, Math.floor((value - 35) / 40)));
140
- return `\x1b[${background ? 48 : 38};5;${16 + 36 * level(r) + 6 * level(g) + level(b)}m`;
141
- }
142
-
143
- /** An image as rows of half-blocks: each cell's upper half is the foreground colour, its lower half the background. */
144
- export function halfBlocks(pixels: Pixels, columns: number, rows: number, trueColor = true): string[] {
145
- const cells = fitCells({ widthPx: pixels.width, heightPx: pixels.height }, columns, rows);
146
- const lines: string[] = [];
147
- const stepX = pixels.width / cells.columns;
148
- const stepY = pixels.height / (cells.rows * 2);
149
- for (let row = 0; row < cells.rows; row += 1) {
150
- let line = "";
151
- for (let column = 0; column < cells.columns; column += 1) {
152
- const x0 = Math.floor(column * stepX);
153
- const x1 = Math.floor((column + 1) * stepX);
154
- const top = averageColor(pixels, x0, Math.floor(row * 2 * stepY), x1, Math.floor((row * 2 + 1) * stepY));
155
- const bottom = averageColor(pixels, x0, Math.floor((row * 2 + 1) * stepY), x1, Math.floor((row * 2 + 2) * stepY));
156
- line += `${colour(top, false, trueColor)}${colour(bottom, true, trueColor)}▀`;
157
- }
158
- lines.push(`${line}\x1b[0m`);
159
- }
160
- return lines;
161
- }
162
-
163
- function describe(preview: ImagePreview): string {
164
- if ("error" in preview) return `${basename(preview.path)}: ${preview.error}`;
165
- return `${basename(preview.path)} · ${preview.dimensions.widthPx}×${preview.dimensions.heightPx} ${preview.mime.slice(6).toUpperCase()}`;
166
- }
167
-
168
- /**
169
- * An option's image drawn in at most `columns` × `rows` cells, with a caption
170
- * line under it naming the file; or, where it cannot be drawn, lines saying
171
- * what and where it is.
172
- */
173
- export function imageLines(preview: ImagePreview, columns: number, rows: number, theme?: LobbyTheme): string[] {
174
- const caption = paint(theme, "dim", describe(preview));
175
- if ("error" in preview) return [paint(theme, "warning", `Image not shown: ${describe(preview)}`)];
176
- const room = Math.max(1, rows - 1);
177
- const how = mode();
178
- if (how === "native") {
179
- const size = fitCells(preview.dimensions, columns, room);
180
- const drawn = renderImage(preview.base64, preview.dimensions, { maxWidthCells: size.columns, maxHeightCells: size.rows, moveCursor: false, ...(preview.kittyId ? { imageId: preview.kittyId } : {}) });
181
- if (drawn) {
182
- if (drawn.imageId) preview.kittyId = drawn.imageId;
183
- return [drawn.sequence, ...Array.from({ length: Math.max(0, drawn.rows - 1) }, () => ""), caption];
184
- }
185
- }
186
- if (how !== "off") {
187
- const pixels = pixelsOf(preview);
188
- if (typeof pixels !== "string") {
189
- const trueColor = getCapabilities().trueColor !== false;
190
- const key = `${columns}x${room}:${trueColor}`;
191
- preview.blocks ??= new Map();
192
- let drawn = preview.blocks.get(key);
193
- if (!drawn) {
194
- drawn = halfBlocks(pixels, columns, room, trueColor);
195
- preview.blocks.set(key, drawn);
196
- }
197
- return [...drawn, caption];
198
- }
199
- return [paint(theme, "muted", `${describe(preview)}: ${pixels}; open ${preview.path} to see it`)];
200
- }
201
- return [paint(theme, "muted", `${describe(preview)}: open ${preview.path} to see it`)];
202
- }
package/src/ask/png.ts DELETED
@@ -1,179 +0,0 @@
1
- /**
2
- * A small PNG decoder: every colour type and bit depth, interlaced or not,
3
- * into 8-bit RGBA. Enough to draw a screenshot as coloured blocks in any
4
- * terminal; no dependency (zlib is Node's).
5
- */
6
- import { inflateSync } from "node:zlib";
7
-
8
- export interface Pixels {
9
- width: number;
10
- height: number;
11
- /** RGBA, 4 bytes per pixel, row by row. */
12
- data: Uint8Array;
13
- }
14
-
15
- const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
16
- /** Larger images are not decoded (a screenshot is far smaller). */
17
- const MAX_PIXELS = 50_000_000;
18
- const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
19
- /** Adam7: each pass's first column and row, and the steps between them. */
20
- const ADAM7 = [
21
- [0, 0, 8, 8],
22
- [4, 0, 8, 8],
23
- [0, 4, 4, 8],
24
- [2, 0, 4, 4],
25
- [0, 2, 2, 4],
26
- [1, 0, 2, 2],
27
- [0, 1, 1, 2],
28
- ] as const;
29
-
30
- export function isPng(bytes: Uint8Array): boolean {
31
- return bytes.length >= 8 && SIGNATURE.every((value, index) => bytes[index] === value);
32
- }
33
-
34
- function paeth(a: number, b: number, c: number): number {
35
- const p = a + b - c;
36
- const pa = Math.abs(p - a);
37
- const pb = Math.abs(p - b);
38
- const pc = Math.abs(p - c);
39
- return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
40
- }
41
-
42
- /** Undo the per-row filters of one (sub)image in place, returning its raw rows. */
43
- function unfilter(source: Uint8Array, offset: number, rowBytes: number, rows: number, bpp: number): { rows: Uint8Array[]; next: number } {
44
- const out: Uint8Array[] = [];
45
- let previous = new Uint8Array(rowBytes);
46
- let at = offset;
47
- for (let row = 0; row < rows; row += 1) {
48
- if (at + 1 + rowBytes > source.length) throw new Error("the PNG image data is cut short");
49
- const filter = source[at]!;
50
- const line = source.slice(at + 1, at + 1 + rowBytes);
51
- at += 1 + rowBytes;
52
- for (let index = 0; index < rowBytes; index += 1) {
53
- const left = index >= bpp ? line[index - bpp]! : 0;
54
- const up = previous[index]!;
55
- const corner = index >= bpp ? previous[index - bpp]! : 0;
56
- switch (filter) {
57
- case 0:
58
- break;
59
- case 1:
60
- line[index] = (line[index]! + left) & 255;
61
- break;
62
- case 2:
63
- line[index] = (line[index]! + up) & 255;
64
- break;
65
- case 3:
66
- line[index] = (line[index]! + ((left + up) >> 1)) & 255;
67
- break;
68
- case 4:
69
- line[index] = (line[index]! + paeth(left, up, corner)) & 255;
70
- break;
71
- default:
72
- throw new Error(`unknown PNG filter ${filter}`);
73
- }
74
- }
75
- out.push(line);
76
- previous = line;
77
- }
78
- return { rows: out, next: at };
79
- }
80
-
81
- /** Decode a PNG into RGBA pixels; throws with a reason when it cannot. */
82
- export function decodePng(bytes: Uint8Array): Pixels {
83
- if (!isPng(bytes)) throw new Error("not a PNG file");
84
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
85
- let width = 0;
86
- let height = 0;
87
- let depth = 8;
88
- let type = 6;
89
- let interlace = 0;
90
- let palette: Uint8Array | undefined;
91
- let alphas: Uint8Array | undefined;
92
- let transparent: number[] | undefined;
93
- const data: Uint8Array[] = [];
94
- for (let at = 8; at + 8 <= bytes.length; ) {
95
- const length = view.getUint32(at);
96
- const name = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
97
- const body = bytes.subarray(at + 8, at + 8 + length);
98
- at += 12 + length;
99
- if (name === "IHDR") {
100
- const header = new DataView(body.buffer, body.byteOffset, body.byteLength);
101
- width = header.getUint32(0);
102
- height = header.getUint32(4);
103
- depth = body[8]!;
104
- type = body[9]!;
105
- interlace = body[12]!;
106
- } else if (name === "PLTE") palette = body;
107
- else if (name === "tRNS") {
108
- if (type === 3) alphas = body;
109
- else if (type === 0 && body.length >= 2) transparent = [(body[0]! << 8) | body[1]!];
110
- else if (type === 2 && body.length >= 6) transparent = [(body[0]! << 8) | body[1]!, (body[2]! << 8) | body[3]!, (body[4]! << 8) | body[5]!];
111
- } else if (name === "IDAT") data.push(body);
112
- else if (name === "IEND") break;
113
- }
114
- const channels = CHANNELS[type];
115
- if (!width || !height || channels === undefined) throw new Error("the PNG header is missing or unsupported");
116
- if (width * height > MAX_PIXELS) throw new Error(`the PNG is too large to draw (${width}×${height})`);
117
- if (type === 3 && !palette) throw new Error("the PNG palette is missing");
118
- const joined = new Uint8Array(data.reduce((total, chunk) => total + chunk.length, 0));
119
- let offset = 0;
120
- for (const chunk of data) {
121
- joined.set(chunk, offset);
122
- offset += chunk.length;
123
- }
124
- const raw = inflateSync(joined);
125
- const bitsPerPixel = channels * depth;
126
- const bpp = Math.max(1, bitsPerPixel >> 3);
127
- const out = new Uint8Array(width * height * 4);
128
- const max = (1 << depth) - 1;
129
-
130
- const sample = (row: Uint8Array, index: number): number => {
131
- if (depth === 8) return row[index]!;
132
- if (depth === 16) return (row[index * 2]! << 8) | row[index * 2 + 1]!;
133
- const bit = index * depth;
134
- return (row[bit >> 3]! >> (8 - depth - (bit & 7))) & max;
135
- };
136
- const to8 = (value: number) => (depth === 16 ? value >> 8 : depth === 8 ? value : Math.round((value * 255) / max));
137
-
138
- const put = (rowData: Uint8Array, column: number, x: number, y: number) => {
139
- const target = (y * width + x) * 4;
140
- const base = column * channels;
141
- if (type === 3) {
142
- const index = sample(rowData, column);
143
- out[target] = palette![index * 3] ?? 0;
144
- out[target + 1] = palette![index * 3 + 1] ?? 0;
145
- out[target + 2] = palette![index * 3 + 2] ?? 0;
146
- out[target + 3] = alphas?.[index] ?? 255;
147
- return;
148
- }
149
- const values = Array.from({ length: channels }, (_, channel) => sample(rowData, base + channel));
150
- const gray = type === 0 || type === 4;
151
- const [r, g, b] = gray ? [values[0]!, values[0]!, values[0]!] : [values[0]!, values[1]!, values[2]!];
152
- const alpha = type === 4 ? to8(values[1]!) : type === 6 ? to8(values[3]!) : 255;
153
- const clear = transparent && (gray ? values[0] === transparent[0] : values[0] === transparent[0] && values[1] === transparent[1] && values[2] === transparent[2]);
154
- out[target] = to8(r);
155
- out[target + 1] = to8(g);
156
- out[target + 2] = to8(b);
157
- out[target + 3] = clear ? 0 : alpha;
158
- };
159
-
160
- if (interlace === 0) {
161
- const { rows } = unfilter(raw, 0, Math.ceil((width * bitsPerPixel) / 8), height, bpp);
162
- rows.forEach((row, y) => {
163
- for (let x = 0; x < width; x += 1) put(row, x, x, y);
164
- });
165
- } else {
166
- let at = 0;
167
- for (const [x0, y0, dx, dy] of ADAM7) {
168
- const passWidth = Math.ceil((width - x0) / dx);
169
- const passHeight = Math.ceil((height - y0) / dy);
170
- if (passWidth <= 0 || passHeight <= 0) continue;
171
- const pass = unfilter(raw, at, Math.ceil((passWidth * bitsPerPixel) / 8), passHeight, bpp);
172
- at = pass.next;
173
- pass.rows.forEach((row, py) => {
174
- for (let px = 0; px < passWidth; px += 1) put(row, px, x0 + px * dx, y0 + py * dy);
175
- });
176
- }
177
- }
178
- return { width, height, data: out };
179
- }