pi-edit-session-in-place 0.1.0 → 0.1.2
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 +31 -10
- package/extensions/edit-session-in-place.ts +156 -18
- package/package.json +20 -10
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.67.2`
|
|
10
|
+
- `@mariozechner/pi-tui` `0.67.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.67.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
|
-
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
|
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 = "
|
|
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
|
|
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
|
|
324
|
-
if (!
|
|
440
|
+
const editorCommand = resolveExternalEditorCommand(process.env);
|
|
441
|
+
if (!editorCommand) {
|
|
325
442
|
return;
|
|
326
443
|
}
|
|
327
444
|
|
|
328
445
|
const currentText = this.editor.getText();
|
|
329
|
-
|
|
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(
|
|
458
|
+
fs.writeFileSync(tempFile, currentText, { encoding: "utf-8", flag: "wx", mode: 0o600 });
|
|
333
459
|
this.tui.stop();
|
|
334
460
|
|
|
335
|
-
const [
|
|
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
|
-
|
|
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
|
-
|
|
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 (${
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
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,15 +40,22 @@
|
|
|
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
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
42
|
-
"@mariozechner/pi-tui": "^0.
|
|
43
|
-
"@types/node": "^
|
|
44
|
-
"typescript": "^
|
|
47
|
+
"@mariozechner/pi-coding-agent": "^0.67.2",
|
|
48
|
+
"@mariozechner/pi-tui": "^0.67.2",
|
|
49
|
+
"@types/node": "^25.6.0",
|
|
50
|
+
"typescript": "^6.0.2"
|
|
51
|
+
},
|
|
52
|
+
"overrides": {
|
|
53
|
+
"basic-ftp": "5.2.2"
|
|
45
54
|
},
|
|
46
55
|
"pi": {
|
|
47
56
|
"extensions": [
|
|
48
57
|
"./extensions"
|
|
49
58
|
]
|
|
50
|
-
}
|
|
59
|
+
},
|
|
60
|
+
"packageManager": "npm@11.12.1"
|
|
51
61
|
}
|