pi-edit-session-in-place 0.1.0
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/LICENSE +21 -0
- package/README.md +97 -0
- package/extensions/edit-session-in-place.ts +477 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mitch Fultz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# pi edit-session-in-place
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Adds `/edit-turn`
|
|
8
|
+
- Adds a global hotkey: `Ctrl+Shift+E`
|
|
9
|
+
- Lets you choose an earlier user message from the current branch
|
|
10
|
+
- Rewinds pi to that point in the same session file
|
|
11
|
+
- Loads your edited text back into the main editor
|
|
12
|
+
- Treats an empty submit as **delete this message and continue from here**
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Install from npm with pi:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pi install npm:pi-edit-session-in-place
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or install directly from GitHub with pi:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install https://github.com/fitchmultz/pi-edit-session-in-place
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then reload pi from inside the app with:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
/reload
|
|
32
|
+
```
|
|
33
|
+
|
|
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
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Inside pi:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
/edit-turn
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or press:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
Ctrl+Shift+E
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Message picker behavior
|
|
55
|
+
|
|
56
|
+
- Shows earlier user messages from the current branch only
|
|
57
|
+
- Uses a viewport so long threads stay navigable
|
|
58
|
+
- Orders messages **oldest → newest**
|
|
59
|
+
- Starts with the **newest** message selected at the bottom
|
|
60
|
+
- `↑` moves to older messages, `↓` moves to newer ones
|
|
61
|
+
- `PageUp` / `PageDown` jump faster
|
|
62
|
+
|
|
63
|
+
### Editor behavior
|
|
64
|
+
|
|
65
|
+
- `Ctrl+X` clears the entire selected message instantly
|
|
66
|
+
- `Enter` submits the edited message
|
|
67
|
+
- `Shift+Enter` inserts a newline
|
|
68
|
+
- `Escape` cancels without changing history
|
|
69
|
+
- `Ctrl+G` opens your external editor if `$VISUAL` or `$EDITOR` is set
|
|
70
|
+
|
|
71
|
+
If you clear the message and submit an empty value, the selected message is effectively deleted: pi rewinds to just before that message and leaves the main editor empty so you can type a new prompt.
|
|
72
|
+
|
|
73
|
+
## Behavior notes
|
|
74
|
+
|
|
75
|
+
- Works in interactive mode; non-interactive modes do not show the picker/editor UI
|
|
76
|
+
- Later messages on the abandoned branch are not deleted from the session file; they remain reachable through `/tree`
|
|
77
|
+
- If the selected message contains images, the extension warns that re-editing or deleting it will drop the images and keep only text behavior
|
|
78
|
+
- The extension only offers text-bearing user messages for editing; image-only or whitespace-only user messages are skipped
|
|
79
|
+
- Queued messages must be cleared before using the command
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
This repo includes a small regression test in:
|
|
84
|
+
|
|
85
|
+
- `tests/edit-session-in-place.test.ts`
|
|
86
|
+
|
|
87
|
+
Checks:
|
|
88
|
+
|
|
89
|
+
- message extraction from mixed session content
|
|
90
|
+
- oldest-to-newest ordering for the picker
|
|
91
|
+
- skipping image-only and whitespace-only user messages
|
|
92
|
+
- preserving the image-warning flag for mixed text+image messages
|
|
93
|
+
|
|
94
|
+
## Files
|
|
95
|
+
|
|
96
|
+
- `extensions/edit-session-in-place.ts` — publishable extension implementation
|
|
97
|
+
- `tests/edit-session-in-place.test.ts` — regression test for message extraction and ordering
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Let the user rewind to and re-edit or delete an earlier user message in the current session branch.
|
|
3
|
+
* Responsibilities: Provide a selector UI, a fast clear-all edit UI, and tree navigation that rewinds to the selected point.
|
|
4
|
+
* Scope: Single publishable pi extension plus pure helpers exported for regression tests.
|
|
5
|
+
* Usage: Install as a pi package and invoke with /edit-turn or Ctrl+Shift+E.
|
|
6
|
+
* Invariants/Assumptions: Operates on the current branch only; later branch history remains in /tree; empty submit means delete.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
CustomEditor,
|
|
15
|
+
DynamicBorder,
|
|
16
|
+
keyHint,
|
|
17
|
+
rawKeyHint,
|
|
18
|
+
type ExtensionAPI,
|
|
19
|
+
type ExtensionCommandContext,
|
|
20
|
+
type KeybindingsManager,
|
|
21
|
+
type SessionEntry,
|
|
22
|
+
type Theme,
|
|
23
|
+
} from "@mariozechner/pi-coding-agent";
|
|
24
|
+
import {
|
|
25
|
+
Container,
|
|
26
|
+
Editor,
|
|
27
|
+
SelectList,
|
|
28
|
+
Spacer,
|
|
29
|
+
Text,
|
|
30
|
+
matchesKey,
|
|
31
|
+
type EditorTheme,
|
|
32
|
+
type Focusable,
|
|
33
|
+
type TUI,
|
|
34
|
+
} from "@mariozechner/pi-tui";
|
|
35
|
+
|
|
36
|
+
const HOTKEY = "ctrl+shift+e";
|
|
37
|
+
const CLEAR_ALL_KEY = "ctrl+x";
|
|
38
|
+
const COMMAND_NAME = "edit-turn";
|
|
39
|
+
const COMMAND_TEXT = `/${COMMAND_NAME}`;
|
|
40
|
+
const SELECT_TITLE = "Pick a previous user message to edit";
|
|
41
|
+
const EDIT_TITLE = "Edit previous user message";
|
|
42
|
+
const PREVIEW_MAX_LENGTH = 90;
|
|
43
|
+
const SELECTOR_MAX_VISIBLE = 12;
|
|
44
|
+
const SELECTOR_PAGE_STEP = SELECTOR_MAX_VISIBLE - 1;
|
|
45
|
+
|
|
46
|
+
type TextContentBlock = {
|
|
47
|
+
type?: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type ImageContentBlock = {
|
|
52
|
+
type?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type EditableUserMessage = {
|
|
56
|
+
entryId: string;
|
|
57
|
+
text: string;
|
|
58
|
+
hasImages: boolean;
|
|
59
|
+
label: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let draftBeforeHotkey: string | undefined;
|
|
63
|
+
|
|
64
|
+
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max));
|
|
65
|
+
const collapseWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
|
|
66
|
+
|
|
67
|
+
const truncate = (text: string, maxLength: number) =>
|
|
68
|
+
text.length <= maxLength ? text : `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
69
|
+
|
|
70
|
+
const formatTimestamp = (timestamp: string) => timestamp.slice(0, 16).replace("T", " ");
|
|
71
|
+
|
|
72
|
+
const createEditorTheme = (theme: Theme): EditorTheme => ({
|
|
73
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
74
|
+
selectList: {
|
|
75
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
76
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
77
|
+
description: (text) => theme.fg("muted", text),
|
|
78
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
79
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const extractEditableText = (content: unknown): { text: string | undefined; hasImages: boolean } => {
|
|
84
|
+
if (typeof content === "string") {
|
|
85
|
+
const text = content.trim();
|
|
86
|
+
return { text: text.length > 0 ? content : undefined, hasImages: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!Array.isArray(content)) {
|
|
90
|
+
return { text: undefined, hasImages: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const textParts: string[] = [];
|
|
94
|
+
let hasImages = false;
|
|
95
|
+
|
|
96
|
+
for (const block of content) {
|
|
97
|
+
if (!block || typeof block !== "object") {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const textBlock = block as TextContentBlock;
|
|
102
|
+
if (textBlock.type === "text" && typeof textBlock.text === "string") {
|
|
103
|
+
textParts.push(textBlock.text);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const imageBlock = block as ImageContentBlock;
|
|
108
|
+
if (imageBlock.type === "image") {
|
|
109
|
+
hasImages = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const joined = textParts.join("\n");
|
|
114
|
+
return {
|
|
115
|
+
text: joined.trim().length > 0 ? joined : undefined,
|
|
116
|
+
hasImages,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const getEditableMessages = (branch: SessionEntry[]): EditableUserMessage[] => {
|
|
121
|
+
const editable: EditableUserMessage[] = [];
|
|
122
|
+
const userEntries = branch.filter(
|
|
123
|
+
(entry): entry is SessionEntry & { type: "message"; message: { role: "user"; content: unknown } } =>
|
|
124
|
+
entry.type === "message" && entry.message.role === "user",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
for (const entry of userEntries) {
|
|
128
|
+
const { text, hasImages } = extractEditableText(entry.message.content);
|
|
129
|
+
if (!text) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const previewSource = collapseWhitespace(text.split("\n").find((line) => line.trim().length > 0) ?? text);
|
|
134
|
+
const preview = truncate(previewSource, PREVIEW_MAX_LENGTH);
|
|
135
|
+
const suffix = hasImages ? " [drops images]" : "";
|
|
136
|
+
const index = editable.length + 1;
|
|
137
|
+
editable.push({
|
|
138
|
+
entryId: entry.id,
|
|
139
|
+
text,
|
|
140
|
+
hasImages,
|
|
141
|
+
label: `${index}. ${formatTimestamp(entry.timestamp)} — ${preview}${suffix}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return editable;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
class EditableMessageSelector extends Container {
|
|
149
|
+
private readonly tui: TUI;
|
|
150
|
+
private readonly keybindings: KeybindingsManager;
|
|
151
|
+
private readonly messages: EditableUserMessage[];
|
|
152
|
+
private readonly selectList: SelectList;
|
|
153
|
+
private readonly onSelect: (message: EditableUserMessage) => void;
|
|
154
|
+
private readonly onCancel: () => void;
|
|
155
|
+
private selectedIndex: number;
|
|
156
|
+
|
|
157
|
+
constructor(
|
|
158
|
+
tui: TUI,
|
|
159
|
+
theme: Theme,
|
|
160
|
+
keybindings: KeybindingsManager,
|
|
161
|
+
title: string,
|
|
162
|
+
messages: EditableUserMessage[],
|
|
163
|
+
onSelect: (message: EditableUserMessage) => void,
|
|
164
|
+
onCancel: () => void,
|
|
165
|
+
) {
|
|
166
|
+
super();
|
|
167
|
+
this.tui = tui;
|
|
168
|
+
this.keybindings = keybindings;
|
|
169
|
+
this.messages = messages;
|
|
170
|
+
this.onSelect = onSelect;
|
|
171
|
+
this.onCancel = onCancel;
|
|
172
|
+
this.selectedIndex = Math.max(0, messages.length - 1);
|
|
173
|
+
|
|
174
|
+
this.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
175
|
+
this.addChild(new Spacer(1));
|
|
176
|
+
this.addChild(new Text(theme.fg("accent", title), 1, 0));
|
|
177
|
+
this.addChild(new Spacer(1));
|
|
178
|
+
|
|
179
|
+
this.selectList = new SelectList(
|
|
180
|
+
messages.map((message) => ({ value: message.entryId, label: message.label })),
|
|
181
|
+
SELECTOR_MAX_VISIBLE,
|
|
182
|
+
createEditorTheme(theme).selectList,
|
|
183
|
+
{ minPrimaryColumnWidth: 56, maxPrimaryColumnWidth: 120 },
|
|
184
|
+
);
|
|
185
|
+
this.selectList.setSelectedIndex(this.selectedIndex);
|
|
186
|
+
this.addChild(this.selectList);
|
|
187
|
+
this.addChild(new Spacer(1));
|
|
188
|
+
this.addChild(
|
|
189
|
+
new Text(
|
|
190
|
+
[
|
|
191
|
+
rawKeyHint("↑", "older"),
|
|
192
|
+
rawKeyHint("↓", "newer"),
|
|
193
|
+
keyHint("tui.select.pageUp", "jump up"),
|
|
194
|
+
keyHint("tui.select.pageDown", "jump down"),
|
|
195
|
+
keyHint("tui.select.confirm", "edit"),
|
|
196
|
+
keyHint("tui.select.cancel", "cancel"),
|
|
197
|
+
].join(" "),
|
|
198
|
+
1,
|
|
199
|
+
0,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
this.addChild(new Spacer(1));
|
|
203
|
+
this.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private setSelectedIndex(index: number) {
|
|
207
|
+
this.selectedIndex = clamp(index, 0, this.messages.length - 1);
|
|
208
|
+
this.selectList.setSelectedIndex(this.selectedIndex);
|
|
209
|
+
this.tui.requestRender();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
handleInput(data: string): void {
|
|
213
|
+
if (this.keybindings.matches(data, "tui.select.up")) {
|
|
214
|
+
this.setSelectedIndex(this.selectedIndex - 1);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (this.keybindings.matches(data, "tui.select.down")) {
|
|
219
|
+
this.setSelectedIndex(this.selectedIndex + 1);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this.keybindings.matches(data, "tui.select.pageUp")) {
|
|
224
|
+
this.setSelectedIndex(this.selectedIndex - SELECTOR_PAGE_STEP);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (this.keybindings.matches(data, "tui.select.pageDown")) {
|
|
229
|
+
this.setSelectedIndex(this.selectedIndex + SELECTOR_PAGE_STEP);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
234
|
+
const selected = this.messages[this.selectedIndex];
|
|
235
|
+
if (selected) {
|
|
236
|
+
this.onSelect(selected);
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
242
|
+
this.onCancel();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
class ReeditMessageEditor extends Container implements Focusable {
|
|
248
|
+
private readonly editor: Editor;
|
|
249
|
+
private readonly tui: TUI;
|
|
250
|
+
private readonly keybindings: KeybindingsManager;
|
|
251
|
+
private readonly onCancel: () => void;
|
|
252
|
+
private _focused = false;
|
|
253
|
+
|
|
254
|
+
get focused(): boolean {
|
|
255
|
+
return this._focused;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
set focused(value: boolean) {
|
|
259
|
+
this._focused = value;
|
|
260
|
+
this.editor.focused = value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
constructor(
|
|
264
|
+
tui: TUI,
|
|
265
|
+
theme: Theme,
|
|
266
|
+
keybindings: KeybindingsManager,
|
|
267
|
+
title: string,
|
|
268
|
+
prefill: string,
|
|
269
|
+
onSubmit: (value: string) => void,
|
|
270
|
+
onCancel: () => void,
|
|
271
|
+
) {
|
|
272
|
+
super();
|
|
273
|
+
this.tui = tui;
|
|
274
|
+
this.keybindings = keybindings;
|
|
275
|
+
this.onCancel = onCancel;
|
|
276
|
+
|
|
277
|
+
this.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
278
|
+
this.addChild(new Spacer(1));
|
|
279
|
+
this.addChild(new Text(theme.fg("accent", title), 1, 0));
|
|
280
|
+
this.addChild(new Spacer(1));
|
|
281
|
+
|
|
282
|
+
this.editor = new Editor(tui, createEditorTheme(theme));
|
|
283
|
+
this.editor.setText(prefill);
|
|
284
|
+
this.editor.onSubmit = (value) => onSubmit(value);
|
|
285
|
+
this.addChild(this.editor);
|
|
286
|
+
this.addChild(new Spacer(1));
|
|
287
|
+
|
|
288
|
+
const hasExternalEditor = Boolean(process.env.VISUAL || process.env.EDITOR);
|
|
289
|
+
const hint = [
|
|
290
|
+
keyHint("tui.select.confirm", "submit"),
|
|
291
|
+
keyHint("tui.input.newLine", "newline"),
|
|
292
|
+
rawKeyHint("ctrl+x", "clear all"),
|
|
293
|
+
rawKeyHint("empty+enter", "delete"),
|
|
294
|
+
keyHint("tui.select.cancel", "cancel"),
|
|
295
|
+
...(hasExternalEditor ? [keyHint("app.editor.external", "external editor")] : []),
|
|
296
|
+
].join(" ");
|
|
297
|
+
this.addChild(new Text(hint, 1, 0));
|
|
298
|
+
this.addChild(new Spacer(1));
|
|
299
|
+
this.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
handleInput(data: string): void {
|
|
303
|
+
if (matchesKey(data, CLEAR_ALL_KEY)) {
|
|
304
|
+
this.editor.setText("");
|
|
305
|
+
this.tui.requestRender();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
310
|
+
this.onCancel();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (this.keybindings.matches(data, "app.editor.external")) {
|
|
315
|
+
this.openExternalEditor();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.editor.handleInput(data);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private openExternalEditor() {
|
|
323
|
+
const editorCmd = process.env.VISUAL || process.env.EDITOR;
|
|
324
|
+
if (!editorCmd) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const currentText = this.editor.getText();
|
|
329
|
+
const tmpFile = path.join(os.tmpdir(), `pi-reedit-message-${Date.now()}.md`);
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
fs.writeFileSync(tmpFile, currentText, "utf-8");
|
|
333
|
+
this.tui.stop();
|
|
334
|
+
|
|
335
|
+
const [editor, ...editorArgs] = editorCmd.split(" ");
|
|
336
|
+
const result = spawnSync(editor, [...editorArgs, tmpFile], {
|
|
337
|
+
stdio: "inherit",
|
|
338
|
+
shell: process.platform === "win32",
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (result.status === 0) {
|
|
342
|
+
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
|
|
343
|
+
this.editor.setText(newContent);
|
|
344
|
+
}
|
|
345
|
+
} finally {
|
|
346
|
+
try {
|
|
347
|
+
fs.unlinkSync(tmpFile);
|
|
348
|
+
} catch {
|
|
349
|
+
// Ignore cleanup errors.
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.tui.start();
|
|
353
|
+
this.tui.requestRender(true);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const selectEditableMessage = async (ctx: ExtensionCommandContext, messages: EditableUserMessage[]) =>
|
|
359
|
+
ctx.ui.custom<EditableUserMessage | undefined>((tui, theme, keybindings, done) =>
|
|
360
|
+
new EditableMessageSelector(tui, theme, keybindings, SELECT_TITLE, messages, (message) => done(message), () => done(undefined)),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const editTextInCustomEditor = async (ctx: ExtensionCommandContext, prefill: string) =>
|
|
364
|
+
ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
|
|
365
|
+
new ReeditMessageEditor(tui, theme, keybindings, EDIT_TITLE, prefill, (value) => done(value), () => done(undefined)),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const restoreDraftIfNeeded = (ctx: ExtensionCommandContext) => {
|
|
369
|
+
if (draftBeforeHotkey === undefined) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
ctx.ui.setEditorText(draftBeforeHotkey);
|
|
374
|
+
draftBeforeHotkey = undefined;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const clearSavedDraft = () => {
|
|
378
|
+
draftBeforeHotkey = undefined;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const handleEditTurn = async (ctx: ExtensionCommandContext) => {
|
|
382
|
+
if (!ctx.hasUI) {
|
|
383
|
+
clearSavedDraft();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (ctx.hasPendingMessages()) {
|
|
388
|
+
ctx.ui.notify("Queued messages are pending. Press Escape first, then try again.", "warning");
|
|
389
|
+
restoreDraftIfNeeded(ctx);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (!ctx.isIdle()) {
|
|
394
|
+
ctx.abort();
|
|
395
|
+
await ctx.waitForIdle();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const editableMessages = getEditableMessages(ctx.sessionManager.getBranch());
|
|
399
|
+
if (editableMessages.length === 0) {
|
|
400
|
+
ctx.ui.notify("No editable text user messages found on the current branch.", "warning");
|
|
401
|
+
restoreDraftIfNeeded(ctx);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const selected = await selectEditableMessage(ctx, editableMessages);
|
|
406
|
+
if (!selected) {
|
|
407
|
+
restoreDraftIfNeeded(ctx);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (selected.hasImages) {
|
|
412
|
+
const keepGoing = await ctx.ui.confirm(
|
|
413
|
+
"Drop images?",
|
|
414
|
+
"That message contains images. Editing or deleting it here will keep only the text and drop the images. Continue?",
|
|
415
|
+
);
|
|
416
|
+
if (!keepGoing) {
|
|
417
|
+
restoreDraftIfNeeded(ctx);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const editedText = await editTextInCustomEditor(ctx, selected.text);
|
|
423
|
+
if (editedText === undefined) {
|
|
424
|
+
restoreDraftIfNeeded(ctx);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const isDelete = editedText.trim().length === 0;
|
|
429
|
+
const result = await ctx.navigateTree(selected.entryId, { summarize: false });
|
|
430
|
+
if (result.cancelled) {
|
|
431
|
+
restoreDraftIfNeeded(ctx);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
clearSavedDraft();
|
|
436
|
+
ctx.ui.setEditorText(isDelete ? "" : editedText);
|
|
437
|
+
ctx.ui.notify(
|
|
438
|
+
isDelete
|
|
439
|
+
? "Message deleted. Type a new prompt to continue from that point."
|
|
440
|
+
: "Edited message loaded. Press Enter to continue from that point.",
|
|
441
|
+
"info",
|
|
442
|
+
);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
class EditSessionInPlaceEditor extends CustomEditor {
|
|
446
|
+
handleInput(data: string): void {
|
|
447
|
+
if (matchesKey(data, HOTKEY)) {
|
|
448
|
+
draftBeforeHotkey = this.getText();
|
|
449
|
+
this.setText(COMMAND_TEXT);
|
|
450
|
+
super.handleInput("\r");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
super.handleInput(data);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export default function editSessionInPlace(pi: ExtensionAPI) {
|
|
459
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
460
|
+
description: `Select and re-edit a previous user message on the current branch (${HOTKEY})`,
|
|
461
|
+
handler: async (_args, ctx) => {
|
|
462
|
+
await handleEditTurn(ctx);
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
pi.on("session_start", async () => {
|
|
467
|
+
clearSavedDraft();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
pi.on("session_shutdown", async () => {
|
|
471
|
+
clearSavedDraft();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
pi.on("session_start", (_event, ctx) => {
|
|
475
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => new EditSessionInPlaceEditor(tui, theme, keybindings));
|
|
476
|
+
});
|
|
477
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-edit-session-in-place",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi extension that lets you re-edit or delete an earlier user message in the current session branch",
|
|
5
|
+
"author": "Mitch Fultz (https://github.com/fitchmultz)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-extension",
|
|
12
|
+
"extension",
|
|
13
|
+
"session",
|
|
14
|
+
"history",
|
|
15
|
+
"typescript"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/fitchmultz/pi-edit-session-in-place.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/fitchmultz/pi-edit-session-in-place/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/fitchmultz/pi-edit-session-in-place#readme",
|
|
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"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"extensions",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
38
|
+
"@mariozechner/pi-tui": "*"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@mariozechner/pi-coding-agent": "^0.65.2",
|
|
42
|
+
"@mariozechner/pi-tui": "^0.65.2",
|
|
43
|
+
"@types/node": "^24.6.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
},
|
|
46
|
+
"pi": {
|
|
47
|
+
"extensions": [
|
|
48
|
+
"./extensions"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|