Skip to content

Commit 8d75fda

Browse files
committed
Merge branch 'emdash/feat-tty-91o'
2 parents 1dafc86 + 5c7c158 commit 8d75fda

3 files changed

Lines changed: 306 additions & 7 deletions

File tree

pi-packages/pi-image-attachments/index.ts

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,88 @@
11
import { createRequire } from "node:module";
22
import path from "node:path";
33
import { pathToFileURL } from "node:url";
4-
import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
5-
import { getKeybindings } from "@mariozechner/pi-tui";
4+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
65
import {
76
loadImageContentFromPath,
87
maybeResizeImage,
98
readImageContentFromPath,
109
type ImageResizer,
1110
} from "./src/image-content.ts";
1211
import { registerImageAttachmentsExtension } from "./src/extension-runtime.ts";
12+
import type { EditorBaseConstructor, EditorKeybindings } from "./src/editor-factory.ts";
1313
import { looksLikeImagePath } from "./src/path-utils.ts";
1414

1515
let cachedResizerPromise: Promise<ImageResizer | null> | undefined;
16+
let cachedEditorKeybindings: EditorKeybindings | undefined;
17+
let editorKeybindingsLoadPromise: Promise<void> | undefined;
18+
19+
function createFallbackCustomEditor(): EditorBaseConstructor {
20+
return class FallbackCustomEditor {
21+
private text = "";
22+
23+
setText(text: string): void {
24+
this.text = text;
25+
}
26+
27+
getText(): string {
28+
return this.text;
29+
}
30+
31+
insertTextAtCursor(text: string): void {
32+
this.text += text;
33+
}
34+
35+
handleInput(data: string): void {
36+
this.text += data;
37+
}
38+
39+
getExpandedText(): string {
40+
return this.text;
41+
}
42+
43+
isShowingAutocomplete(): boolean {
44+
return false;
45+
}
46+
};
47+
}
48+
49+
function createFallbackEditorKeybindings(): EditorKeybindings {
50+
return {
51+
matches(data: string, action: string): boolean {
52+
return action === "tui.input.submit" && (data === "\r" || data === "\n" || data === "\x1bOM");
53+
},
54+
};
55+
}
56+
57+
function loadEditorKeybindings(): Promise<void> {
58+
if (editorKeybindingsLoadPromise) {
59+
return editorKeybindingsLoadPromise;
60+
}
61+
62+
editorKeybindingsLoadPromise = import("@mariozechner/pi-tui")
63+
.then((mod) => {
64+
const candidate = (mod as { getKeybindings?: unknown }).getKeybindings;
65+
const resolved = typeof candidate === "function" ? candidate() : candidate;
66+
67+
if (resolved && typeof (resolved as EditorKeybindings).matches === "function") {
68+
cachedEditorKeybindings = resolved as EditorKeybindings;
69+
}
70+
})
71+
.catch(() => {
72+
// If pi-tui is unavailable, the extension keeps working with the fallback matcher.
73+
});
74+
75+
return editorKeybindingsLoadPromise;
76+
}
77+
78+
function getEditorKeybindings(): EditorKeybindings {
79+
if (!cachedEditorKeybindings) {
80+
void loadEditorKeybindings();
81+
return createFallbackEditorKeybindings();
82+
}
83+
84+
return cachedEditorKeybindings;
85+
}
1686

1787
async function loadPiImageResizer(): Promise<ImageResizer | null> {
1888
if (cachedResizerPromise) {
@@ -51,10 +121,24 @@ async function loadPiImageResizer(): Promise<ImageResizer | null> {
51121
return cachedResizerPromise;
52122
}
53123

54-
export default function (pi: ExtensionAPI): void {
124+
async function loadCustomEditor(): Promise<EditorBaseConstructor> {
125+
try {
126+
const mod = await import("@mariozechner/pi-coding-agent");
127+
return mod.CustomEditor as EditorBaseConstructor;
128+
} catch {
129+
return createFallbackCustomEditor();
130+
}
131+
}
132+
133+
export default async function (pi: ExtensionAPI): Promise<void> {
134+
const [CustomEditor] = await Promise.all([
135+
loadCustomEditor(),
136+
loadEditorKeybindings(),
137+
]);
138+
55139
registerImageAttachmentsExtension(pi, {
56140
BaseEditor: CustomEditor as any,
57-
getEditorKeybindings: getKeybindings,
141+
getEditorKeybindings,
58142
resolveCwd: () => process.cwd(),
59143
looksLikeImagePath,
60144
readImageContentFromPath,

pi-packages/pi-image-attachments/src/editor-factory.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createRequire } from "node:module";
12
import path from "node:path";
23
import type { ImageContent } from "./content.ts";
34
import {
@@ -45,7 +46,7 @@ export type EditorBaseConstructor = new (...args: any[]) => EditorBase;
4546

4647
export type AttachmentEditorDeps = {
4748
BaseEditor: EditorBaseConstructor;
48-
getEditorKeybindings: () => EditorKeybindings;
49+
getEditorKeybindings?: EditorKeybindings | (() => EditorKeybindings);
4950
resolveCwd: () => string;
5051
looksLikeImagePath: (filePath: string) => boolean;
5152
readImageContentFromPath: (filePath: string) => ImageContent | null;
@@ -56,6 +57,61 @@ export type AttachmentEditorDeps = {
5657
const BRACKETED_PASTE_START = "\u001b[200~";
5758
const BRACKETED_PASTE_END = "\u001b[201~";
5859

60+
type SubmitKeyMatcher = (data: string) => boolean;
61+
62+
let cachedSubmitKeyMatcher: SubmitKeyMatcher | null | undefined;
63+
64+
function fallbackSubmitKeyMatcher(data: string): boolean {
65+
return data === "\r" || data === "\n" || data === "\x1bOM";
66+
}
67+
68+
function resolveSubmitKeyMatcher(): SubmitKeyMatcher {
69+
if (cachedSubmitKeyMatcher !== undefined) {
70+
return cachedSubmitKeyMatcher ?? fallbackSubmitKeyMatcher;
71+
}
72+
73+
try {
74+
const require = createRequire(import.meta.url);
75+
const mod = require("@mariozechner/pi-tui") as {
76+
Key?: { enter?: string };
77+
matchesKey?: (data: string, key: string) => boolean;
78+
};
79+
80+
if (typeof mod.matchesKey === "function" && typeof mod.Key?.enter === "string") {
81+
cachedSubmitKeyMatcher = (data: string) => mod.matchesKey!(data, mod.Key!.enter!);
82+
return cachedSubmitKeyMatcher;
83+
}
84+
} catch {
85+
// Fallback below.
86+
}
87+
88+
cachedSubmitKeyMatcher = null;
89+
return fallbackSubmitKeyMatcher;
90+
}
91+
92+
function createFallbackEditorKeybindings(): EditorKeybindings {
93+
return {
94+
matches(data: string, action: string): boolean {
95+
return action === "tui.input.submit" && resolveSubmitKeyMatcher()(data);
96+
},
97+
};
98+
}
99+
100+
function resolveEditorKeybindings(
101+
candidate: AttachmentEditorDeps["getEditorKeybindings"]
102+
): EditorKeybindings {
103+
try {
104+
const resolved = typeof candidate === "function" ? candidate() : candidate;
105+
if (resolved && typeof resolved.matches === "function") {
106+
return resolved;
107+
}
108+
} catch {
109+
// Fall back below.
110+
}
111+
112+
return createFallbackEditorKeybindings();
113+
}
114+
59115
function extractBracketedPaste(data: string): string | null {
60116
if (!data.startsWith(BRACKETED_PASTE_START) || !data.endsWith(BRACKETED_PASTE_END)) {
61117
return null;
@@ -97,9 +153,10 @@ export function createImageAttachmentEditor(deps: AttachmentEditorDeps) {
97153
return;
98154
}
99155

100-
const editorKeys = deps.getEditorKeybindings();
156+
const editorKeybindings = this.getEditorKeybindings();
101157
const isSubmit =
102-
editorKeys.matches(data, "tui.input.submit") && !(this.isShowingAutocomplete?.() ?? false);
158+
editorKeybindings.matches(data, "tui.input.submit") &&
159+
!(this.isShowingAutocomplete?.() ?? false);
103160
if (isSubmit && this.attachments.length > 0) {
104161
const fullText = (this.getExpandedText?.() ?? this.getText()).trim();
105162
const usedAttachments = sortByPlaceholderNumber(
@@ -208,6 +265,10 @@ export function createImageAttachmentEditor(deps: AttachmentEditorDeps) {
208265
);
209266
}
210267

268+
private getEditorKeybindings(): EditorKeybindings {
269+
return resolveEditorKeybindings(deps.getEditorKeybindings);
270+
}
271+
211272
private publishDraft(): void {
212273
this.hooks.publishDraft(this.attachments);
213274
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { afterEach, expect, test } from "bun:test";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { createImageAttachmentEditor } from "../src/editor-factory.ts";
6+
import type { AttachmentEditorDeps, DraftAttachment, PendingSubmission } from "../src/editor-factory.ts";
7+
import type { ImageContent } from "../src/content.ts";
8+
9+
class FakeBaseEditor {
10+
private text = "";
11+
12+
setText(text: string): void {
13+
this.text = text;
14+
}
15+
16+
getText(): string {
17+
return this.text;
18+
}
19+
20+
insertTextAtCursor(text: string): void {
21+
this.text += text;
22+
}
23+
24+
handleInput(_data: string): void {
25+
// No-op: the attachment editor under test owns the interesting behavior.
26+
}
27+
}
28+
29+
function createTempImagePath(): { dir: string; imagePath: string } {
30+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-image-attachments-"));
31+
const imagePath = path.join(dir, "shot.png");
32+
fs.writeFileSync(imagePath, "");
33+
return { dir, imagePath };
34+
}
35+
36+
function buildDeps(options: {
37+
imagePath: string;
38+
getEditorKeybindings?: AttachmentEditorDeps["getEditorKeybindings"];
39+
}): AttachmentEditorDeps {
40+
const image: ImageContent = {
41+
type: "image",
42+
data: "image-data",
43+
mimeType: "image/png",
44+
};
45+
46+
return {
47+
BaseEditor: FakeBaseEditor as unknown as AttachmentEditorDeps["BaseEditor"],
48+
getEditorKeybindings: options.getEditorKeybindings,
49+
resolveCwd: () => path.dirname(options.imagePath),
50+
looksLikeImagePath: (filePath: string) => filePath === options.imagePath,
51+
readImageContentFromPath: (filePath: string) => (filePath === options.imagePath ? image : null),
52+
};
53+
}
54+
55+
function createHarness(options: {
56+
imagePath: string;
57+
getEditorKeybindings?: AttachmentEditorDeps["getEditorKeybindings"];
58+
}) {
59+
const publishedDrafts: DraftAttachment[][] = [];
60+
let pendingSubmission: PendingSubmission | undefined;
61+
let sentImages: ImageContent[] | undefined;
62+
63+
const EditorClass = createImageAttachmentEditor(buildDeps(options));
64+
const editor = new EditorClass({
65+
publishDraft: (attachments) => {
66+
publishedDrafts.push([...attachments]);
67+
},
68+
queuePendingSubmission: (submission) => {
69+
pendingSubmission = submission;
70+
},
71+
sendImagesOnly: (images) => {
72+
sentImages = images;
73+
},
74+
});
75+
76+
return {
77+
editor,
78+
publishedDrafts,
79+
get pendingSubmission() {
80+
return pendingSubmission;
81+
},
82+
get sentImages() {
83+
return sentImages;
84+
},
85+
};
86+
}
87+
88+
let cleanupDirs: string[] = [];
89+
90+
afterEach(() => {
91+
for (const dir of cleanupDirs) {
92+
fs.rmSync(dir, { recursive: true, force: true });
93+
}
94+
cleanupDirs = [];
95+
});
96+
97+
test("falls back to enter detection when getEditorKeybindings is missing", () => {
98+
const { dir, imagePath } = createTempImagePath();
99+
cleanupDirs.push(dir);
100+
101+
const harness = createHarness({ imagePath });
102+
103+
harness.editor.insertTextAtCursor(imagePath);
104+
harness.editor.insertTextAtCursor("hello");
105+
106+
expect(() => harness.editor.handleInput("\r")).not.toThrow();
107+
expect(harness.pendingSubmission?.matchText).toBe("[Image #1] hello");
108+
expect(harness.pendingSubmission?.transformedText).toBe("hello");
109+
expect(harness.pendingSubmission?.images).toHaveLength(1);
110+
expect(harness.sentImages).toBeUndefined();
111+
});
112+
113+
test("accepts a pre-resolved keybinding object", () => {
114+
const { dir, imagePath } = createTempImagePath();
115+
cleanupDirs.push(dir);
116+
117+
const harness = createHarness({
118+
imagePath,
119+
getEditorKeybindings: {
120+
matches(data, action) {
121+
return action === "tui.input.submit" && data === "\r";
122+
},
123+
},
124+
});
125+
126+
harness.editor.insertTextAtCursor(imagePath);
127+
harness.editor.insertTextAtCursor("hello");
128+
harness.editor.handleInput("\r");
129+
130+
expect(harness.pendingSubmission?.transformedText).toBe("hello");
131+
expect(harness.pendingSubmission?.images).toHaveLength(1);
132+
});
133+
134+
test("the extension module imports without eagerly loading Pi runtime packages", async () => {
135+
const mod = await import("../index.ts");
136+
expect(typeof mod.default).toBe("function");
137+
});
138+
139+
test("the extension registers with a minimal Pi stub when runtime packages are absent", async () => {
140+
const events: string[] = [];
141+
const pi = {
142+
on(event: string, _handler: (...args: any[]) => any) {
143+
events.push(event);
144+
},
145+
sendUserMessage() {
146+
// No-op for this smoke test.
147+
},
148+
};
149+
150+
const mod = await import("../index.ts");
151+
await expect(mod.default(pi as any)).resolves.toBeUndefined();
152+
expect(events).toContain("session_start");
153+
expect(events).toContain("input");
154+
});

0 commit comments

Comments
 (0)