pi-edit-session-in-place 0.1.0 → 0.1.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/README.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  A [pi](https://github.com/badlogic/pi-mono) extension that lets you rewind to an earlier user message in the current branch, then either **edit it in place** or **delete it and continue from there**.
4
4
 
5
+ ## Compatibility
6
+
7
+ Tested with:
8
+
9
+ - `@mariozechner/pi-coding-agent` `0.65.2`
10
+ - `@mariozechner/pi-tui` `0.65.2`
11
+ - Node.js `>=20.6.0`
12
+
13
+ The package follows current pi packaging guidance and publishes pi core packages as peer dependencies with `"*"`, while local development and verification in this repo target pi `0.65.2`.
14
+
5
15
  ## What it does
6
16
 
7
17
  - Adds `/edit-turn`
@@ -31,12 +41,6 @@ Then reload pi from inside the app with:
31
41
  /reload
32
42
  ```
33
43
 
34
- For local development you can point pi at the extension directly:
35
-
36
- ```bash
37
- pi -e ./extensions/edit-session-in-place.ts
38
- ```
39
-
40
44
  ## Usage
41
45
 
42
46
  Inside pi:
@@ -80,18 +84,35 @@ If you clear the message and submit an empty value, the selected message is effe
80
84
 
81
85
  ## Development
82
86
 
83
- This repo includes a small regression test in:
87
+ For local development you can point pi at the extension directly:
88
+
89
+ ```bash
90
+ pi -e ./extensions/edit-session-in-place.ts
91
+ ```
92
+
93
+ Local verification:
94
+
95
+ ```bash
96
+ npm run verify
97
+ ```
98
+
99
+ That runs:
84
100
 
85
- - `tests/edit-session-in-place.test.ts`
101
+ - `npm test` — compiles the TypeScript test fixtures to `.test-dist/` and runs them with Node's built-in test runner
102
+ - `npm run typecheck` — strict TypeScript type-checking
103
+ - `npm pack --dry-run` — publishability check for the npm package contents
86
104
 
87
- Checks:
105
+ Current regression coverage in `tests/edit-session-in-place.test.ts` includes:
88
106
 
89
107
  - message extraction from mixed session content
90
108
  - oldest-to-newest ordering for the picker
91
109
  - skipping image-only and whitespace-only user messages
92
110
  - preserving the image-warning flag for mixed text+image messages
111
+ - `$VISUAL`/`$EDITOR` resolution rules
112
+ - external editor command parsing with quoting/escaping
113
+ - trimming exactly one trailing newline from external-editor output
93
114
 
94
115
  ## Files
95
116
 
96
117
  - `extensions/edit-session-in-place.ts` — publishable extension implementation
97
- - `tests/edit-session-in-place.test.ts` — regression test for message extraction and ordering
118
+ - `tests/edit-session-in-place.test.ts` — regression tests for message extraction, ordering, and external-editor helpers
@@ -24,6 +24,7 @@ import {
24
24
  import {
25
25
  Container,
26
26
  Editor,
27
+ Key,
27
28
  SelectList,
28
29
  Spacer,
29
30
  Text,
@@ -33,7 +34,8 @@ import {
33
34
  type TUI,
34
35
  } from "@mariozechner/pi-tui";
35
36
 
36
- const HOTKEY = "ctrl+shift+e";
37
+ const HOTKEY = Key.ctrlShift("e");
38
+ const HOTKEY_LABEL = "Ctrl+Shift+E";
37
39
  const CLEAR_ALL_KEY = "ctrl+x";
38
40
  const COMMAND_NAME = "edit-turn";
39
41
  const COMMAND_TEXT = `/${COMMAND_NAME}`;
@@ -42,6 +44,8 @@ const EDIT_TITLE = "Edit previous user message";
42
44
  const PREVIEW_MAX_LENGTH = 90;
43
45
  const SELECTOR_MAX_VISIBLE = 12;
44
46
  const SELECTOR_PAGE_STEP = SELECTOR_MAX_VISIBLE - 1;
47
+ const EXTERNAL_EDITOR_TMP_PREFIX = "pi-reedit-message-";
48
+ const EXTERNAL_EDITOR_FILE_NAME = "message.md";
45
49
 
46
50
  type TextContentBlock = {
47
51
  type?: string;
@@ -59,6 +63,11 @@ export type EditableUserMessage = {
59
63
  label: string;
60
64
  };
61
65
 
66
+ export type ExternalEditorCommand = {
67
+ executable: string;
68
+ args: string[];
69
+ };
70
+
62
71
  let draftBeforeHotkey: string | undefined;
63
72
 
64
73
  const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max));
@@ -80,6 +89,114 @@ const createEditorTheme = (theme: Theme): EditorTheme => ({
80
89
  },
81
90
  });
82
91
 
92
+ export const resolveExternalEditorCommand = (env: NodeJS.ProcessEnv) => {
93
+ const visual = env.VISUAL?.trim();
94
+ if (visual) {
95
+ return visual;
96
+ }
97
+
98
+ const editor = env.EDITOR?.trim();
99
+ return editor || undefined;
100
+ };
101
+
102
+ export const parseExternalEditorCommand = (command: string): ExternalEditorCommand => {
103
+ const parts: string[] = [];
104
+ let current = "";
105
+ let quote: "'" | '"' | undefined;
106
+ let tokenStarted = false;
107
+
108
+ const pushCurrent = () => {
109
+ if (!tokenStarted) {
110
+ return;
111
+ }
112
+
113
+ parts.push(current);
114
+ current = "";
115
+ tokenStarted = false;
116
+ };
117
+
118
+ for (let index = 0; index < command.length; index += 1) {
119
+ const character = command[index];
120
+ if (!character) {
121
+ continue;
122
+ }
123
+
124
+ if (quote === "'") {
125
+ if (character === "'") {
126
+ quote = undefined;
127
+ } else {
128
+ current += character;
129
+ }
130
+ tokenStarted = true;
131
+ continue;
132
+ }
133
+
134
+ if (quote === '"') {
135
+ if (character === '"') {
136
+ quote = undefined;
137
+ continue;
138
+ }
139
+
140
+ if (character === "\\") {
141
+ const next = command[index + 1];
142
+ if (next && ['"', "\\", "$", "`"].includes(next)) {
143
+ current += next;
144
+ index += 1;
145
+ } else {
146
+ current += character;
147
+ }
148
+ tokenStarted = true;
149
+ continue;
150
+ }
151
+
152
+ current += character;
153
+ tokenStarted = true;
154
+ continue;
155
+ }
156
+
157
+ if (/\s/.test(character)) {
158
+ pushCurrent();
159
+ continue;
160
+ }
161
+
162
+ if (character === "'" || character === '"') {
163
+ quote = character;
164
+ tokenStarted = true;
165
+ continue;
166
+ }
167
+
168
+ if (character === "\\") {
169
+ const next = command[index + 1];
170
+ if (next && /[\s'"\\]/.test(next)) {
171
+ current += next;
172
+ index += 1;
173
+ } else {
174
+ current += character;
175
+ }
176
+ tokenStarted = true;
177
+ continue;
178
+ }
179
+
180
+ current += character;
181
+ tokenStarted = true;
182
+ }
183
+
184
+ if (quote) {
185
+ throw new Error("Unterminated quote in $VISUAL/$EDITOR.");
186
+ }
187
+
188
+ pushCurrent();
189
+
190
+ const [executable, ...args] = parts;
191
+ if (!executable) {
192
+ throw new Error("External editor command is empty.");
193
+ }
194
+
195
+ return { executable, args };
196
+ };
197
+
198
+ export const trimSingleTrailingNewline = (text: string) => text.replace(/\r?\n$/, "");
199
+
83
200
  export const extractEditableText = (content: unknown): { text: string | undefined; hasImages: boolean } => {
84
201
  if (typeof content === "string") {
85
202
  const text = content.trim();
@@ -285,7 +402,7 @@ class ReeditMessageEditor extends Container implements Focusable {
285
402
  this.addChild(this.editor);
286
403
  this.addChild(new Spacer(1));
287
404
 
288
- const hasExternalEditor = Boolean(process.env.VISUAL || process.env.EDITOR);
405
+ const hasExternalEditor = Boolean(resolveExternalEditorCommand(process.env));
289
406
  const hint = [
290
407
  keyHint("tui.select.confirm", "submit"),
291
408
  keyHint("tui.input.newLine", "newline"),
@@ -320,38 +437,45 @@ class ReeditMessageEditor extends Container implements Focusable {
320
437
  }
321
438
 
322
439
  private openExternalEditor() {
323
- const editorCmd = process.env.VISUAL || process.env.EDITOR;
324
- if (!editorCmd) {
440
+ const editorCommand = resolveExternalEditorCommand(process.env);
441
+ if (!editorCommand) {
325
442
  return;
326
443
  }
327
444
 
328
445
  const currentText = this.editor.getText();
329
- const tmpFile = path.join(os.tmpdir(), `pi-reedit-message-${Date.now()}.md`);
446
+ let parsedCommand: ExternalEditorCommand;
447
+ try {
448
+ parsedCommand = parseExternalEditorCommand(editorCommand);
449
+ } catch {
450
+ return;
451
+ }
452
+
453
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), EXTERNAL_EDITOR_TMP_PREFIX));
454
+ const tempFile = path.join(tempDir, EXTERNAL_EDITOR_FILE_NAME);
455
+ let nextText: string | undefined;
330
456
 
331
457
  try {
332
- fs.writeFileSync(tmpFile, currentText, "utf-8");
458
+ fs.writeFileSync(tempFile, currentText, { encoding: "utf-8", flag: "wx", mode: 0o600 });
333
459
  this.tui.stop();
334
460
 
335
- const [editor, ...editorArgs] = editorCmd.split(" ");
336
- const result = spawnSync(editor, [...editorArgs, tmpFile], {
461
+ const result = spawnSync(parsedCommand.executable, [...parsedCommand.args, tempFile], {
337
462
  stdio: "inherit",
338
463
  shell: process.platform === "win32",
339
464
  });
340
465
 
341
- if (result.status === 0) {
342
- const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
343
- this.editor.setText(newContent);
466
+ if (result.status === 0 && !result.error) {
467
+ nextText = trimSingleTrailingNewline(fs.readFileSync(tempFile, "utf-8"));
344
468
  }
345
469
  } finally {
346
- try {
347
- fs.unlinkSync(tmpFile);
348
- } catch {
349
- // Ignore cleanup errors.
350
- }
351
-
470
+ fs.rmSync(tempDir, { recursive: true, force: true });
352
471
  this.tui.start();
353
472
  this.tui.requestRender(true);
354
473
  }
474
+
475
+ if (nextText !== undefined) {
476
+ this.editor.setText(nextText);
477
+ this.tui.requestRender();
478
+ }
355
479
  }
356
480
  }
357
481
 
@@ -457,12 +581,26 @@ class EditSessionInPlaceEditor extends CustomEditor {
457
581
 
458
582
  export default function editSessionInPlace(pi: ExtensionAPI) {
459
583
  pi.registerCommand(COMMAND_NAME, {
460
- description: `Select and re-edit a previous user message on the current branch (${HOTKEY})`,
584
+ description: `Select and re-edit a previous user message on the current branch (${HOTKEY_LABEL})`,
461
585
  handler: async (_args, ctx) => {
462
586
  await handleEditTurn(ctx);
463
587
  },
464
588
  });
465
589
 
590
+ // pi 0.65.2 shortcut handlers receive ExtensionContext, which cannot run slash commands
591
+ // or navigate the session tree. Keep the custom editor hotkey path for execution, and
592
+ // register the shortcut here so it appears in /hotkeys and other shortcut diagnostics.
593
+ pi.registerShortcut(HOTKEY, {
594
+ description: `Edit a previous user message (${HOTKEY_LABEL})`,
595
+ handler: (ctx) => {
596
+ if (!ctx.hasUI) {
597
+ return;
598
+ }
599
+
600
+ ctx.ui.notify(`Press ${HOTKEY_LABEL} in the main editor to edit a previous message.`, "info");
601
+ },
602
+ });
603
+
466
604
  pi.on("session_start", async () => {
467
605
  clearSavedDraft();
468
606
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-edit-session-in-place",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "pi extension that lets you re-edit or delete an earlier user message in the current session branch",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -23,10 +23,13 @@
23
23
  },
24
24
  "homepage": "https://github.com/fitchmultz/pi-edit-session-in-place#readme",
25
25
  "scripts": {
26
- "test": "node --experimental-strip-types tests/edit-session-in-place.test.ts",
27
- "typecheck": "tsc --noEmit",
28
- "check": "npm test && npm run typecheck",
29
- "prepublishOnly": "npm run check && npm pack --dry-run"
26
+ "clean:test": "node -e \"require('node:fs').rmSync('.test-dist', { recursive: true, force: true })\"",
27
+ "build:test": "npm run clean:test && tsc -p tsconfig.test.json",
28
+ "test": "npm run build:test && node --test .test-dist/tests/*.test.js",
29
+ "typecheck": "tsc --noEmit -p tsconfig.json",
30
+ "check": "npm run verify",
31
+ "verify": "npm test && npm run typecheck && npm pack --dry-run",
32
+ "prepublishOnly": "npm run verify"
30
33
  },
31
34
  "files": [
32
35
  "extensions",
@@ -37,12 +40,18 @@
37
40
  "@mariozechner/pi-coding-agent": "*",
38
41
  "@mariozechner/pi-tui": "*"
39
42
  },
43
+ "engines": {
44
+ "node": ">=20.6.0"
45
+ },
40
46
  "devDependencies": {
41
47
  "@mariozechner/pi-coding-agent": "^0.65.2",
42
48
  "@mariozechner/pi-tui": "^0.65.2",
43
49
  "@types/node": "^24.6.0",
44
50
  "typescript": "^5.9.3"
45
51
  },
52
+ "overrides": {
53
+ "basic-ftp": "5.2.2"
54
+ },
46
55
  "pi": {
47
56
  "extensions": [
48
57
  "./extensions"