pi-edit-session-in-place 0.1.17 → 0.1.19

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 CHANGED
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.1.19] - 2026-06-15
8
+
9
+ ### Changed
10
+ - updated the local pi development baseline to `@earendil-works/pi-coding-agent` / `@earendil-works/pi-tui` `0.79.4` and refreshed the npm lockfile
11
+
12
+ ### Validation
13
+ - ran `npm run verify` under pi `0.79.4`
14
+
15
+ ## [0.1.18] - 2026-06-05
16
+
17
+ ### Fixed
18
+ - preserved expanded editor text when the edit hotkey or external-editor path sees pi paste markers
19
+ - wrapped any existing custom editor when installing the `Ctrl+Shift+E` hotkey path, while preserving app action handlers for `CustomEditor`-style bases
20
+ - reported malformed or failing `$VISUAL` / `$EDITOR` launches as warnings instead of silently ignoring them
21
+
7
22
  ## [0.1.17] - 2026-06-04
8
23
 
9
24
  ### 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.getText();
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.status === 0 && !result.error) {
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(tui, theme, keybindings, EDIT_TITLE, prefill, (value) => done(value), () => done(undefined)),
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
- class EditSessionInPlaceEditor extends CustomEditor {
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
- tui: TUI,
582
- theme: EditorTheme,
583
- keybindings: KeybindingsManager,
625
+ private readonly base: EditorComponent,
584
626
  private readonly getCommandText: () => string,
585
627
  ) {
586
- super(tui, theme, keybindings);
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.getText();
592
- this.setText(this.getCommandText());
593
- super.handleInput("\r");
761
+ draftBeforeHotkey = getExpandedEditorText(this.base);
762
+ this.base.setText(this.getCommandText());
763
+ this.base.handleInput("\r");
594
764
  return;
595
765
  }
596
766
 
597
- super.handleInput(data);
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.setEditorComponent((tui, theme, keybindings) =>
613
- new EditSessionInPlaceEditor(tui, theme, keybindings, () => getEditTurnCommandText(pi.getCommands())),
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.17",
3
+ "version": "0.1.19",
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",
@@ -45,8 +45,8 @@
45
45
  "node": ">=22 <25"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-coding-agent": "^0.78.1",
49
- "@earendil-works/pi-tui": "^0.78.1",
48
+ "@earendil-works/pi-coding-agent": "0.79.4",
49
+ "@earendil-works/pi-tui": "0.79.4",
50
50
  "@types/node": "^25.9.1",
51
51
  "typescript": "^6.0.3"
52
52
  },