Skip to content

Commit 2f8afd6

Browse files
authored
feat: add pi-no-soft-cursor extension package (#10)
Strips the editor's reverse-video soft cursor and forces the terminal's native hardware cursor on. ## What's new **`pi-no-soft-cursor`** — a new extension that: 1. Removes the last reverse-video span from each rendered editor line (the soft cursor block) 2. Enables the terminal's hardware cursor unconditionally in the constructor With this extension, pi's "Show hardware cursor" setting has no effect — the hardware cursor is always on. ## Package contents - `src/strip.ts` — `stripSoftCursor()`: finds the last `\x1b[7m…\x1b[0m` span and unwraps it - `src/index.ts` — `NoCursorEditor` subclass of `CustomEditor` - `test/strip.test.ts` — 14 tests - README, package.json, build config First version (`1.0.0`) — needs manual `npm publish` after merge. Co-authored-by: mgabor3141 <@mgabor3141>
1 parent 72b0e72 commit 2f8afd6

10 files changed

Lines changed: 291 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Monorepo of extensions and libraries for [pi](https://pi.dev). Managed with Yarn
2020
| pi-bash-trim | `packages/bash-trim/` | pi extension |
2121
| pi-desktop-notify | `packages/desktop-notify/` | pi extension + library |
2222
| pi-budget-model | `packages/budget-model/` | library |
23+
| pi-no-soft-cursor | `packages/no-soft-cursor/` | pi extension |
2324

2425
## Workflow
2526

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Utilities for running [pi](https://pi.dev) agents with less babysitting: auto-re
99
Install the extensions together, or pick only the ones you want. Defaults are tuned for good behavior out of the box.
1010

1111
```bash
12-
pi install pi-safeguard pi-bash-trim pi-desktop-notify
12+
pi install pi-safeguard pi-bash-trim pi-desktop-notify pi-no-soft-cursor
1313
```
1414

1515
### [pi-safeguard](packages/safeguard/)
@@ -26,6 +26,10 @@ Smart bash output trimming. Intercepts tool results before they enter the contex
2626

2727
Desktop notifications with terminal focus tracking. Notifications are suppressed while the terminal is in the foreground and only fire when you've tabbed away — so you hear about finished tasks without being interrupted mid-thought. Click-to-focus brings the terminal back. Works on macOS (terminal-notifier) and Linux (notify-send), with compositor support for niri, sway, and hyprland.
2828

29+
### [pi-no-soft-cursor](packages/no-soft-cursor/)
30+
31+
Remove the reverse-video block cursor from the editor. Your terminal already shows a blinking cursor via the hardware cursor marker — the highlighted character block the editor draws on top is just visual noise. This strips it.
32+
2933
## Libraries
3034

3135
### [pi-budget-model](packages/budget-model/)

packages/no-soft-cursor/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# pi-no-soft-cursor
2+
3+
Remove the reverse-video block cursor from pi's editor. Your terminal's native blinking cursor still shows the insertion point — just without the highlighted character block drawn on top.
4+
5+
## Install
6+
7+
```bash
8+
pi install npm:pi-no-soft-cursor
9+
```
10+
11+
## What it does
12+
13+
Pi's text editor renders a "soft cursor": the character under the cursor is shown in reverse video (a highlighted block). Some terminals already show a blinking cursor via the hardware cursor marker, making the soft cursor redundant or visually noisy.
14+
15+
This extension:
16+
17+
1. **Strips the soft cursor** — subclasses the editor and removes the last reverse-video span from each rendered line (the one the editor uses for the cursor block).
18+
2. **Forces the hardware cursor on** — pi has a "Show hardware cursor" setting that is off by default. This extension enables it unconditionally so the terminal's native cursor is always visible.
19+
20+
> **Note:** With this extension active, pi's "Show hardware cursor" setting has no effect — the hardware cursor is always enabled.
21+
22+
## Setup
23+
24+
After installing, **restart pi** for the extension to take effect. `/reload` alone is not sufficient due to a limitation in how pi manages the editor component during reload.
25+
26+
## Configuration
27+
28+
None. Install and it works.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "pi-no-soft-cursor",
3+
"version": "1.0.0",
4+
"description": "Remove the editor's reverse-video soft cursor — use only the terminal's native cursor",
5+
"author": "mgabor3141",
6+
"license": "MIT",
7+
"repository": {
8+
"url": "git+https://github.com/mgabor3141/yapp.git",
9+
"directory": "packages/no-soft-cursor"
10+
},
11+
"keywords": [
12+
"pi-package",
13+
"editor",
14+
"cursor",
15+
"tui"
16+
],
17+
"type": "module",
18+
"main": "dist/index.js",
19+
"types": "dist/index.d.ts",
20+
"exports": {
21+
".": {
22+
"import": "./dist/index.js",
23+
"types": "./dist/index.d.ts"
24+
}
25+
},
26+
"files": [
27+
"dist",
28+
"README.md"
29+
],
30+
"scripts": {
31+
"build": "tsup"
32+
},
33+
"pi": {
34+
"extensions": [
35+
"dist/index.js"
36+
]
37+
},
38+
"peerDependencies": {
39+
"@mariozechner/pi-coding-agent": "*",
40+
"@mariozechner/pi-tui": "*"
41+
},
42+
"devDependencies": {
43+
"@mariozechner/pi-coding-agent": "^0.57.0",
44+
"@mariozechner/pi-tui": "^0.57.0",
45+
"@types/node": "^25.3.5",
46+
"tsup": "^8.5.1",
47+
"typescript": "^5.9.3"
48+
}
49+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* pi-no-soft-cursor — remove the editor's reverse-video "fake" cursor.
3+
*
4+
* The terminal's native (hardware) cursor is forced on so you still see
5+
* a blinking caret at the insertion point. Only the highlighted block that
6+
* the editor draws on top is removed.
7+
*
8+
* Note: with this extension active, pi's "Show hardware cursor" setting
9+
* has no effect — the hardware cursor is always enabled.
10+
*/
11+
12+
import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
13+
14+
import { stripSoftCursor } from "./strip.js";
15+
16+
class NoCursorEditor extends CustomEditor {
17+
constructor(...args: ConstructorParameters<typeof CustomEditor>) {
18+
super(...args);
19+
this.tui.setShowHardwareCursor(true);
20+
}
21+
22+
render(width: number): string[] {
23+
return super.render(width).map(stripSoftCursor);
24+
}
25+
}
26+
27+
export default function (pi: ExtensionAPI) {
28+
pi.on("session_start", (_event, ctx) => {
29+
if (!ctx.hasUI) return;
30+
ctx.ui.setEditorComponent((tui, theme, kb) => new NoCursorEditor(tui, theme, kb));
31+
});
32+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const REVERSE_ON = "\x1b[7m";
2+
const REVERSE_OFF = "\x1b[0m";
3+
4+
/**
5+
* Strip the *last* reverse-video span (`\x1b[7m…\x1b[0m`) from a string,
6+
* keeping the inner content. Only the final occurrence is removed — this
7+
* targets the editor's soft cursor (which is always at the cursor position,
8+
* i.e. the last such span on the line) without disturbing any earlier
9+
* reverse-video markup.
10+
*/
11+
export function stripSoftCursor(line: string): string {
12+
const revEnd = line.lastIndexOf(REVERSE_OFF);
13+
if (revEnd === -1) return line;
14+
15+
const revStart = line.lastIndexOf(REVERSE_ON, revEnd);
16+
if (revStart === -1) return line;
17+
18+
// Make sure this pair is well-formed (no nested open between them)
19+
const contentStart = revStart + REVERSE_ON.length;
20+
if (contentStart > revEnd) return line;
21+
22+
const before = line.slice(0, revStart);
23+
const content = line.slice(contentStart, revEnd);
24+
const after = line.slice(revEnd + REVERSE_OFF.length);
25+
26+
return before + content + after;
27+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it } from "vitest";
2+
import { stripSoftCursor } from "../src/strip.js";
3+
4+
const REV = "\x1b[7m";
5+
const RST = "\x1b[0m";
6+
const BOLD = "\x1b[1m";
7+
const DIM = "\x1b[2m";
8+
const FG = "\x1b[38;2;100;100;100m";
9+
10+
describe("stripSoftCursor", () => {
11+
// --- passthrough cases ---
12+
13+
it("passes plain text through unchanged", () => {
14+
expect(stripSoftCursor("hello world")).toBe("hello world");
15+
});
16+
17+
it("passes empty string through", () => {
18+
expect(stripSoftCursor("")).toBe("");
19+
});
20+
21+
it("returns line unchanged when reverse-on has no matching close", () => {
22+
expect(stripSoftCursor(`before${REV}dangling`)).toBe(`before${REV}dangling`);
23+
});
24+
25+
it("returns line unchanged when only reset exists (no reverse-on)", () => {
26+
expect(stripSoftCursor(`text${RST}more`)).toBe(`text${RST}more`);
27+
});
28+
29+
// --- basic stripping ---
30+
31+
it("strips a single reverse-video span", () => {
32+
expect(stripSoftCursor(`before${REV}X${RST}after`)).toBe("beforeXafter");
33+
});
34+
35+
it("strips reverse-video span with multi-char content", () => {
36+
expect(stripSoftCursor(`${REV}hello${RST}`)).toBe("hello");
37+
});
38+
39+
it("strips reverse-video around a space (end-of-line cursor)", () => {
40+
expect(stripSoftCursor(`text${REV} ${RST}`)).toBe("text ");
41+
});
42+
43+
it("strips reverse-video around unicode grapheme", () => {
44+
expect(stripSoftCursor(`${REV}🚀${RST}`)).toBe("🚀");
45+
});
46+
47+
it("handles line with only reverse-video", () => {
48+
expect(stripSoftCursor(`${REV}X${RST}`)).toBe("X");
49+
});
50+
51+
// --- last-only behavior ---
52+
53+
it("strips only the last reverse-video span when multiple exist", () => {
54+
const input = `${REV}A${RST} gap ${REV}B${RST}`;
55+
expect(stripSoftCursor(input)).toBe(`${REV}A${RST} gap B`);
56+
});
57+
58+
it("handles adjacent reverse-video spans — strips only last", () => {
59+
expect(stripSoftCursor(`${REV}A${RST}${REV}B${RST}`)).toBe(`${REV}A${RST}B`);
60+
});
61+
62+
it("preserves earlier reverse-video in a line with cursor at end", () => {
63+
const line = `${REV}label${RST}: value${REV}X${RST}`;
64+
expect(stripSoftCursor(line)).toBe(`${REV}label${RST}: valueX`);
65+
});
66+
67+
// --- interaction with other ANSI sequences ---
68+
69+
it("preserves bold+reset before the cursor span", () => {
70+
const input = `${BOLD}bold${RST} ${REV}X${RST} plain`;
71+
expect(stripSoftCursor(input)).toBe(`${BOLD}bold${RST} X plain`);
72+
});
73+
74+
it("handles a reset after the cursor span (known boundary)", () => {
75+
// If a \x1b[0m appears after the cursor's \x1b[0m on the same line, the
76+
// function matches the outer pair: last REV to last RST. This eats the
77+
// inner reset and any color opener, but the visible text is preserved.
78+
// In practice the editor never emits color sequences after the cursor on
79+
// content lines, so this path doesn't fire — we test it to document the
80+
// boundary and confirm no text is lost.
81+
const input = `${REV}X${RST}${FG}colored${RST}`;
82+
// Strips from REV(0) to final RST — inner resets and FG become "content"
83+
expect(stripSoftCursor(input)).toBe(`X${RST}${FG}colored`);
84+
});
85+
86+
it("handles color-only lines without reverse-video (border lines)", () => {
87+
// borderColor wraps produce \x1b[38;2;…m…\x1b[0m but no \x1b[7m
88+
const border = `${FG}${"─".repeat(40)}${RST}`;
89+
expect(stripSoftCursor(border)).toBe(border);
90+
});
91+
92+
it("handles dim color wrapping border indicators", () => {
93+
const indicator = `${DIM}─── ↑ 3 more ${RST}${"─".repeat(20)}`;
94+
expect(stripSoftCursor(indicator)).toBe(indicator);
95+
});
96+
97+
// --- realistic editor output ---
98+
99+
it("handles content line: padding + text + cursor + padding", () => {
100+
// Editor renders: " hello[cursor:w]orld "
101+
const line = ` hello${REV}w${RST}orld${" ".repeat(5)}`;
102+
expect(stripSoftCursor(line)).toBe(` helloworld${" ".repeat(5)}`);
103+
});
104+
105+
it("handles content line: cursor at end of text + padding", () => {
106+
// Editor renders: " hello[cursor: ] "
107+
const line = ` hello${REV} ${RST}${" ".repeat(5)}`;
108+
expect(stripSoftCursor(line)).toBe(` hello ${" ".repeat(5)}`);
109+
});
110+
111+
it("handles content line with hardware cursor marker before soft cursor", () => {
112+
// The hardware cursor marker is a zero-width APC sequence placed before the soft cursor
113+
const CURSOR_MARKER = "\x1b_pi:c\x07";
114+
const line = ` hello${CURSOR_MARKER}${REV}w${RST}orld `;
115+
expect(stripSoftCursor(line)).toBe(` hello${CURSOR_MARKER}world `);
116+
});
117+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "src"
6+
},
7+
"include": ["src"]
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from "tsup";
2+
3+
export default defineConfig({
4+
entry: ["src/index.ts"],
5+
format: "esm",
6+
dts: true,
7+
clean: true,
8+
external: ["@mariozechner/pi-coding-agent", "@mariozechner/pi-tui"],
9+
});

yarn.lock

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4454,6 +4454,21 @@ __metadata:
44544454
languageName: unknown
44554455
linkType: soft
44564456

4457+
"pi-no-soft-cursor@workspace:packages/no-soft-cursor":
4458+
version: 0.0.0-use.local
4459+
resolution: "pi-no-soft-cursor@workspace:packages/no-soft-cursor"
4460+
dependencies:
4461+
"@mariozechner/pi-coding-agent": "npm:^0.57.0"
4462+
"@mariozechner/pi-tui": "npm:^0.57.0"
4463+
"@types/node": "npm:^25.3.5"
4464+
tsup: "npm:^8.5.1"
4465+
typescript: "npm:^5.9.3"
4466+
peerDependencies:
4467+
"@mariozechner/pi-coding-agent": "*"
4468+
"@mariozechner/pi-tui": "*"
4469+
languageName: unknown
4470+
linkType: soft
4471+
44574472
"pi-safeguard@workspace:packages/safeguard":
44584473
version: 0.0.0-use.local
44594474
resolution: "pi-safeguard@workspace:packages/safeguard"

0 commit comments

Comments
 (0)