pi-edit-session-in-place 0.1.17 → 0.1.18
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/CHANGELOG.md +7 -0
- package/README.md +2 -1
- package/extensions/edit-session-in-place.ts +188 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [0.1.18] - 2026-06-05
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- preserved expanded editor text when the edit hotkey or external-editor path sees pi paste markers
|
|
11
|
+
- wrapped any existing custom editor when installing the `Ctrl+Shift+E` hotkey path, while preserving app action handlers for `CustomEditor`-style bases
|
|
12
|
+
- reported malformed or failing `$VISUAL` / `$EDITOR` launches as warnings instead of silently ignoring them
|
|
13
|
+
|
|
7
14
|
## [0.1.17] - 2026-06-04
|
|
8
15
|
|
|
9
16
|
### Fixed
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ Ctrl+Shift+E
|
|
|
70
70
|
- `Enter` submits the edited message
|
|
71
71
|
- `Shift+Enter` inserts a newline
|
|
72
72
|
- `Escape` cancels without changing history
|
|
73
|
-
- `Ctrl+G` opens your external editor if `$VISUAL` or `$EDITOR` is set
|
|
73
|
+
- `Ctrl+G` opens your external editor if `$VISUAL` or `$EDITOR` is set; parse or launch failures are reported as warnings
|
|
74
74
|
|
|
75
75
|
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.
|
|
76
76
|
|
|
@@ -82,6 +82,7 @@ If you clear the message and submit an empty value, the selected message is effe
|
|
|
82
82
|
- The extension only offers text-bearing user messages for editing; image-only or whitespace-only user messages are skipped
|
|
83
83
|
- Queued messages must be cleared before using the command
|
|
84
84
|
- The `Ctrl+Shift+E` hotkey is handled by this extension's main-editor component so Pi's registered shortcut dispatcher does not consume it first
|
|
85
|
+
- The hotkey component wraps any previously configured custom editor when possible instead of replacing it
|
|
85
86
|
|
|
86
87
|
## Development
|
|
87
88
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DynamicBorder,
|
|
16
16
|
keyHint,
|
|
17
17
|
rawKeyHint,
|
|
18
|
+
type AppKeybinding,
|
|
18
19
|
type ExtensionAPI,
|
|
19
20
|
type ExtensionCommandContext,
|
|
20
21
|
type KeybindingsManager,
|
|
@@ -28,7 +29,9 @@ import {
|
|
|
28
29
|
SelectList,
|
|
29
30
|
Spacer,
|
|
30
31
|
Text,
|
|
32
|
+
isFocusable,
|
|
31
33
|
matchesKey,
|
|
34
|
+
type EditorComponent,
|
|
32
35
|
type EditorTheme,
|
|
33
36
|
type Focusable,
|
|
34
37
|
type TUI,
|
|
@@ -197,6 +200,9 @@ export const parseExternalEditorCommand = (command: string): ExternalEditorComma
|
|
|
197
200
|
|
|
198
201
|
export const trimSingleTrailingNewline = (text: string) => text.replace(/\r?\n$/, "");
|
|
199
202
|
|
|
203
|
+
export const getExpandedEditorText = (editor: Pick<EditorComponent, "getText" | "getExpandedText">) =>
|
|
204
|
+
editor.getExpandedText?.() ?? editor.getText();
|
|
205
|
+
|
|
200
206
|
export const extractEditableText = (content: unknown): { text: string | undefined; hasImages: boolean } => {
|
|
201
207
|
if (typeof content === "string") {
|
|
202
208
|
const text = content.trim();
|
|
@@ -366,6 +372,7 @@ class ReeditMessageEditor extends Container implements Focusable {
|
|
|
366
372
|
private readonly tui: TUI;
|
|
367
373
|
private readonly keybindings: KeybindingsManager;
|
|
368
374
|
private readonly onCancel: () => void;
|
|
375
|
+
private readonly onError: (message: string) => void;
|
|
369
376
|
private _focused = false;
|
|
370
377
|
|
|
371
378
|
get focused(): boolean {
|
|
@@ -385,11 +392,13 @@ class ReeditMessageEditor extends Container implements Focusable {
|
|
|
385
392
|
prefill: string,
|
|
386
393
|
onSubmit: (value: string) => void,
|
|
387
394
|
onCancel: () => void,
|
|
395
|
+
onError: (message: string) => void,
|
|
388
396
|
) {
|
|
389
397
|
super();
|
|
390
398
|
this.tui = tui;
|
|
391
399
|
this.keybindings = keybindings;
|
|
392
400
|
this.onCancel = onCancel;
|
|
401
|
+
this.onError = onError;
|
|
393
402
|
|
|
394
403
|
this.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
395
404
|
this.addChild(new Spacer(1));
|
|
@@ -442,17 +451,19 @@ class ReeditMessageEditor extends Container implements Focusable {
|
|
|
442
451
|
return;
|
|
443
452
|
}
|
|
444
453
|
|
|
445
|
-
const currentText = this.editor
|
|
454
|
+
const currentText = getExpandedEditorText(this.editor);
|
|
446
455
|
let parsedCommand: ExternalEditorCommand;
|
|
447
456
|
try {
|
|
448
457
|
parsedCommand = parseExternalEditorCommand(editorCommand);
|
|
449
|
-
} catch {
|
|
458
|
+
} catch (error) {
|
|
459
|
+
this.onError(error instanceof Error ? error.message : "Failed to parse $VISUAL/$EDITOR.");
|
|
450
460
|
return;
|
|
451
461
|
}
|
|
452
462
|
|
|
453
463
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), EXTERNAL_EDITOR_TMP_PREFIX));
|
|
454
464
|
const tempFile = path.join(tempDir, EXTERNAL_EDITOR_FILE_NAME);
|
|
455
465
|
let nextText: string | undefined;
|
|
466
|
+
let errorMessage: string | undefined;
|
|
456
467
|
|
|
457
468
|
try {
|
|
458
469
|
fs.writeFileSync(tempFile, currentText, { encoding: "utf-8", flag: "wx", mode: 0o600 });
|
|
@@ -463,7 +474,11 @@ class ReeditMessageEditor extends Container implements Focusable {
|
|
|
463
474
|
shell: process.platform === "win32",
|
|
464
475
|
});
|
|
465
476
|
|
|
466
|
-
if (result.
|
|
477
|
+
if (result.error) {
|
|
478
|
+
errorMessage = `External editor failed: ${result.error.message}`;
|
|
479
|
+
} else if (result.status !== 0) {
|
|
480
|
+
errorMessage = `External editor exited with status ${result.status ?? "unknown"}.`;
|
|
481
|
+
} else {
|
|
467
482
|
nextText = trimSingleTrailingNewline(fs.readFileSync(tempFile, "utf-8"));
|
|
468
483
|
}
|
|
469
484
|
} finally {
|
|
@@ -472,6 +487,11 @@ class ReeditMessageEditor extends Container implements Focusable {
|
|
|
472
487
|
this.tui.requestRender(true);
|
|
473
488
|
}
|
|
474
489
|
|
|
490
|
+
if (errorMessage) {
|
|
491
|
+
this.onError(errorMessage);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
475
495
|
if (nextText !== undefined) {
|
|
476
496
|
this.editor.setText(nextText);
|
|
477
497
|
this.tui.requestRender();
|
|
@@ -486,7 +506,16 @@ const selectEditableMessage = async (ctx: ExtensionCommandContext, messages: Edi
|
|
|
486
506
|
|
|
487
507
|
const editTextInCustomEditor = async (ctx: ExtensionCommandContext, prefill: string) =>
|
|
488
508
|
ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
|
|
489
|
-
new ReeditMessageEditor(
|
|
509
|
+
new ReeditMessageEditor(
|
|
510
|
+
tui,
|
|
511
|
+
theme,
|
|
512
|
+
keybindings,
|
|
513
|
+
EDIT_TITLE,
|
|
514
|
+
prefill,
|
|
515
|
+
(value) => done(value),
|
|
516
|
+
() => done(undefined),
|
|
517
|
+
(message) => ctx.ui.notify(message, "warning"),
|
|
518
|
+
),
|
|
490
519
|
);
|
|
491
520
|
|
|
492
521
|
const restoreDraftIfNeeded = (ctx: ExtensionCommandContext) => {
|
|
@@ -576,25 +605,166 @@ export const getEditTurnCommandText = (commands: Array<{ name: string }>) => {
|
|
|
576
605
|
return `/${candidates.at(-1) ?? COMMAND_NAME}`;
|
|
577
606
|
};
|
|
578
607
|
|
|
579
|
-
|
|
608
|
+
type CustomEditorHooks = {
|
|
609
|
+
actionHandlers: Map<AppKeybinding, () => void>;
|
|
610
|
+
onEscape?: () => void;
|
|
611
|
+
onCtrlD?: () => void;
|
|
612
|
+
onPasteImage?: () => void;
|
|
613
|
+
onExtensionShortcut?: (data: string) => boolean | undefined;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const getCustomEditorHooks = (editor: EditorComponent): (EditorComponent & CustomEditorHooks) | undefined => {
|
|
617
|
+
const candidate = editor as Partial<CustomEditorHooks>;
|
|
618
|
+
return candidate.actionHandlers instanceof Map ? (editor as EditorComponent & CustomEditorHooks) : undefined;
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
class EditSessionInPlaceEditor implements EditorComponent, Focusable {
|
|
622
|
+
private readonly customBase: (EditorComponent & CustomEditorHooks) | undefined;
|
|
623
|
+
|
|
580
624
|
constructor(
|
|
581
|
-
|
|
582
|
-
theme: EditorTheme,
|
|
583
|
-
keybindings: KeybindingsManager,
|
|
625
|
+
private readonly base: EditorComponent,
|
|
584
626
|
private readonly getCommandText: () => string,
|
|
585
627
|
) {
|
|
586
|
-
|
|
628
|
+
this.customBase = getCustomEditorHooks(base);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
get actionHandlers(): Map<AppKeybinding, () => void> | undefined {
|
|
632
|
+
return this.customBase?.actionHandlers;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
get focused(): boolean {
|
|
636
|
+
return isFocusable(this.base) ? this.base.focused : false;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
set focused(value: boolean) {
|
|
640
|
+
if (isFocusable(this.base)) {
|
|
641
|
+
this.base.focused = value;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
get wantsKeyRelease(): boolean | undefined {
|
|
646
|
+
return this.base.wantsKeyRelease;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
get onSubmit(): ((text: string) => void) | undefined {
|
|
650
|
+
return this.base.onSubmit;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
set onSubmit(handler: ((text: string) => void) | undefined) {
|
|
654
|
+
this.base.onSubmit = handler;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
get onChange(): ((text: string) => void) | undefined {
|
|
658
|
+
return this.base.onChange;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
set onChange(handler: ((text: string) => void) | undefined) {
|
|
662
|
+
this.base.onChange = handler;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
get borderColor(): ((str: string) => string) | undefined {
|
|
666
|
+
return this.base.borderColor;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
set borderColor(handler: ((str: string) => string) | undefined) {
|
|
670
|
+
if (this.base.borderColor !== undefined) {
|
|
671
|
+
this.base.borderColor = handler;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
get onEscape(): (() => void) | undefined {
|
|
676
|
+
return this.customBase?.onEscape;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
set onEscape(handler: (() => void) | undefined) {
|
|
680
|
+
if (this.customBase) {
|
|
681
|
+
this.customBase.onEscape = handler;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
get onCtrlD(): (() => void) | undefined {
|
|
686
|
+
return this.customBase?.onCtrlD;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
set onCtrlD(handler: (() => void) | undefined) {
|
|
690
|
+
if (this.customBase) {
|
|
691
|
+
this.customBase.onCtrlD = handler;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
get onPasteImage(): (() => void) | undefined {
|
|
696
|
+
return this.customBase?.onPasteImage;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
set onPasteImage(handler: (() => void) | undefined) {
|
|
700
|
+
if (this.customBase) {
|
|
701
|
+
this.customBase.onPasteImage = handler;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
get onExtensionShortcut(): ((data: string) => boolean | undefined) | undefined {
|
|
706
|
+
return this.customBase?.onExtensionShortcut;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
set onExtensionShortcut(handler: ((data: string) => boolean | undefined) | undefined) {
|
|
710
|
+
if (this.customBase) {
|
|
711
|
+
this.customBase.onExtensionShortcut = handler;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
onAction(action: AppKeybinding, handler: () => void): void {
|
|
716
|
+
this.customBase?.actionHandlers.set(action, handler);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
render(width: number): string[] {
|
|
720
|
+
return this.base.render(width);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
invalidate(): void {
|
|
724
|
+
this.base.invalidate();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
getText(): string {
|
|
728
|
+
return this.base.getText();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
getExpandedText(): string {
|
|
732
|
+
return getExpandedEditorText(this.base);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
setText(text: string): void {
|
|
736
|
+
this.base.setText(text);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
addToHistory(text: string): void {
|
|
740
|
+
this.base.addToHistory?.(text);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
insertTextAtCursor(text: string): void {
|
|
744
|
+
this.base.insertTextAtCursor?.(text);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
setAutocompleteProvider(provider: Parameters<NonNullable<EditorComponent["setAutocompleteProvider"]>>[0]): void {
|
|
748
|
+
this.base.setAutocompleteProvider?.(provider);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
setPaddingX(padding: number): void {
|
|
752
|
+
this.base.setPaddingX?.(padding);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
756
|
+
this.base.setAutocompleteMaxVisible?.(maxVisible);
|
|
587
757
|
}
|
|
588
758
|
|
|
589
759
|
handleInput(data: string): void {
|
|
590
760
|
if (matchesKey(data, HOTKEY)) {
|
|
591
|
-
draftBeforeHotkey = this.
|
|
592
|
-
this.setText(this.getCommandText());
|
|
593
|
-
|
|
761
|
+
draftBeforeHotkey = getExpandedEditorText(this.base);
|
|
762
|
+
this.base.setText(this.getCommandText());
|
|
763
|
+
this.base.handleInput("\r");
|
|
594
764
|
return;
|
|
595
765
|
}
|
|
596
766
|
|
|
597
|
-
|
|
767
|
+
this.base.handleInput(data);
|
|
598
768
|
}
|
|
599
769
|
}
|
|
600
770
|
|
|
@@ -609,9 +779,11 @@ export default function editSessionInPlace(pi: ExtensionAPI) {
|
|
|
609
779
|
pi.on("session_start", (_event, ctx) => {
|
|
610
780
|
clearSavedDraft();
|
|
611
781
|
if (ctx.mode === "tui") {
|
|
612
|
-
ctx.ui.
|
|
613
|
-
|
|
614
|
-
|
|
782
|
+
const previousEditorFactory = ctx.ui.getEditorComponent();
|
|
783
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
784
|
+
const baseEditor = previousEditorFactory?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
|
|
785
|
+
return new EditSessionInPlaceEditor(baseEditor, () => getEditTurnCommandText(pi.getCommands()));
|
|
786
|
+
});
|
|
615
787
|
}
|
|
616
788
|
});
|
|
617
789
|
|
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.18",
|
|
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",
|