Skip to content

Commit 11bb9d3

Browse files
authored
fix(pi-no-soft-cursor): keep soft cursor visible while autocomplete is open (#55)
Fixes #45. ## Root cause When the editor's autocomplete is active (e.g. the `@`-file picker), upstream pi-tui's `Editor.render` does not emit `CURSOR_MARKER`: ```js // pi-tui/dist/components/editor.js const emitCursorMarker = this.focused && !this.autocompleteState; ``` Without that marker, `tui.positionHardwareCursor` hides the hardware cursor. The editor still draws the soft (reverse-video) cursor on the input line, which is the only cursor indicator the user has in that mode. This extension was stripping that span unconditionally, so the user saw no cursor at all while the file picker was open. ## Fix In `patchEditorRender`, skip the strip when `editor.autocompleteState` is truthy. The soft cursor falls back into view until autocomplete closes; once it does, hardware-cursor-only behavior resumes as before. ## Tests Added `test/patch-editor-render.test.ts` covering: - strips when autocomplete is inactive - preserves the soft cursor while autocomplete is active (issue #45) - forces hardware cursor on at construction and on every render - idempotent when patching the same editor twice ## Notes A more aggressive alternative would be to also override the marker suppression so the hardware cursor stays positioned during autocomplete. That would require monkey-patching `Editor.prototype.render` itself; the conservative fix here just falls back to upstream's soft-cursor behavior in that one mode.
1 parent d00db3d commit 11bb9d3

3 files changed

Lines changed: 91 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"pi-no-soft-cursor": patch
3+
---
4+
5+
Keep the soft cursor visible while the editor's autocomplete (e.g. the `@`-file picker) is open, so the cursor doesn't disappear when no hardware-cursor marker is emitted (#45).

packages/no-soft-cursor/src/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ const INTERACTIVE_MODE_PATCHED = Symbol.for("pi-no-soft-cursor.interactive-mode-
2020
type Keybindings = ConstructorParameters<typeof CustomEditor>[2];
2121
type EditorFactory = (tui: TUI, theme: EditorTheme, keybindings: Keybindings) => EditorComponent;
2222
type HardwareCursorCapable = { tui?: { setShowHardwareCursor?(show: boolean): void } };
23-
type PatchedEditor = EditorComponent & HardwareCursorCapable & { [RENDER_PATCHED]?: boolean };
23+
type AutocompleteAware = { autocompleteState?: unknown };
24+
type PatchedEditor = EditorComponent & HardwareCursorCapable & AutocompleteAware & { [RENDER_PATCHED]?: boolean };
2425
type PatchableUI = {
2526
[UI_PATCHED]?: boolean;
2627
setEditorComponent(factory: EditorFactory | undefined): void;
@@ -30,15 +31,21 @@ function forceHardwareCursor(editor: unknown) {
3031
(editor as HardwareCursorCapable | undefined)?.tui?.setShowHardwareCursor?.(true);
3132
}
3233

33-
function patchEditorRender<T extends EditorComponent>(editor: T): T {
34+
export function patchEditorRender<T extends EditorComponent>(editor: T): T {
3435
const patchedEditor = editor as PatchedEditor;
3536
forceHardwareCursor(patchedEditor);
3637
if (patchedEditor[RENDER_PATCHED]) return editor;
3738

3839
const originalRender = editor.render.bind(editor);
3940
patchedEditor.render = ((width: number) => {
4041
forceHardwareCursor(patchedEditor);
41-
return originalRender(width).map(stripSoftCursor);
42+
const lines = originalRender(width);
43+
// When autocomplete (e.g. the @-file picker) is active, upstream pi
44+
// suppresses the hardware-cursor marker but still draws the soft cursor.
45+
// Stripping in that mode would leave no cursor visible at all (issue #45),
46+
// so fall back to the soft cursor until autocomplete closes.
47+
if (patchedEditor.autocompleteState) return lines;
48+
return lines.map(stripSoftCursor);
4249
}) as typeof editor.render;
4350
patchedEditor[RENDER_PATCHED] = true;
4451
return editor;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { EditorComponent } from "@mariozechner/pi-tui";
2+
import { describe, expect, it } from "vitest";
3+
import { patchEditorRender } from "../src/index.js";
4+
5+
const REV = "\x1b[7m";
6+
const RST = "\x1b[0m";
7+
8+
type AutocompleteState = { items: string[] } | null;
9+
10+
interface FakeEditor extends EditorComponent {
11+
autocompleteState: AutocompleteState;
12+
tui: { setShowHardwareCursor(show: boolean): void };
13+
hardwareCursorCalls: boolean[];
14+
}
15+
16+
function makeFakeEditor(linesProvider: () => string[]): FakeEditor {
17+
const editor = {
18+
autocompleteState: null as AutocompleteState,
19+
hardwareCursorCalls: [] as boolean[],
20+
tui: {
21+
setShowHardwareCursor(show: boolean) {
22+
editor.hardwareCursorCalls.push(show);
23+
},
24+
},
25+
render(_width: number) {
26+
return linesProvider();
27+
},
28+
} as unknown as FakeEditor;
29+
return editor;
30+
}
31+
32+
describe("patchEditorRender", () => {
33+
it("strips the soft cursor when autocomplete is inactive", () => {
34+
const editor = makeFakeEditor(() => [`hello${REV}X${RST} `]);
35+
const patched = patchEditorRender(editor);
36+
37+
expect(patched.render(80)).toEqual(["helloX "]);
38+
});
39+
40+
it("preserves the soft cursor while autocomplete is active (issue #45)", () => {
41+
// When the file picker (`@`) is open, pi suppresses the hardware-cursor
42+
// marker, so the soft cursor is the only indicator. Stripping it leaves
43+
// the user with no visible cursor at all.
44+
const editor = makeFakeEditor(() => [`hello${REV}X${RST} `, " src/index.ts"]);
45+
editor.autocompleteState = { items: ["src/index.ts"] };
46+
const patched = patchEditorRender(editor);
47+
48+
expect(patched.render(80)).toEqual([`hello${REV}X${RST} `, " src/index.ts"]);
49+
});
50+
51+
it("forces the hardware cursor on at construction and on each render", () => {
52+
const editor = makeFakeEditor(() => ["text"]);
53+
const patched = patchEditorRender(editor);
54+
55+
expect(editor.hardwareCursorCalls).toEqual([true]);
56+
patched.render(80);
57+
patched.render(80);
58+
expect(editor.hardwareCursorCalls).toEqual([true, true, true]);
59+
});
60+
61+
it("is idempotent: patching the same editor twice does not double-wrap render", () => {
62+
let renderCount = 0;
63+
const editor = makeFakeEditor(() => {
64+
renderCount++;
65+
return [`a${REV}b${RST}`];
66+
});
67+
68+
patchEditorRender(editor);
69+
patchEditorRender(editor);
70+
editor.render(80);
71+
72+
expect(renderCount).toBe(1);
73+
// Original render was called once and stripping was applied once.
74+
expect(editor.render(80)).toEqual(["ab"]);
75+
});
76+
});

0 commit comments

Comments
 (0)