@wwwyo/pi-vim 0.0.0-stage → 0.14.2

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/input-keys.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { matchesKey } from "@earendil-works/pi-tui";
2
+ import { getLineGraphemes } from "./motions.js";
3
+
4
+ // Keyboard-input classification predicates. Pure functions over the raw
5
+ // input chunk a terminal delivers to the editor — no editor state. Shared
6
+ // by the EX mini-mode, insert, and normal-mode dispatch paths.
7
+
8
+ export function isEscapeLikeInput(data: string): boolean {
9
+ return matchesKey(data, "escape") || matchesKey(data, "ctrl+[");
10
+ }
11
+
12
+ export function isEnterLikeInput(data: string): boolean {
13
+ return (
14
+ data === "\r" ||
15
+ data === "\n" ||
16
+ matchesKey(data, "enter") ||
17
+ matchesKey(data, "return")
18
+ );
19
+ }
20
+
21
+ export function isBackspaceLikeInput(data: string): boolean {
22
+ return (
23
+ data === "\x7f" ||
24
+ data === "\x08" ||
25
+ matchesKey(data, "backspace") ||
26
+ matchesKey(data, "ctrl+h")
27
+ );
28
+ }
29
+
30
+ export function isPrintableChunk(data: string): boolean {
31
+ if (data.length === 0) return false;
32
+ for (const char of data) {
33
+ const codePoint = char.codePointAt(0);
34
+ if (codePoint === undefined || codePoint < 32 || codePoint === 127)
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+
40
+ export function isPrintableInput(data: string): boolean {
41
+ return isPrintableChunk(data) && getLineGraphemes(data).length === 1;
42
+ }
43
+
44
+ export function isDigit(data: string): boolean {
45
+ return data.length === 1 && data >= "0" && data <= "9";
46
+ }
47
+
48
+ export function isCountStarter(data: string): boolean {
49
+ return data.length === 1 && data >= "1" && data <= "9";
50
+ }
@@ -0,0 +1,120 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import type { ModeChangeSettings } from "./settings.js";
4
+ import type { Mode } from "./types.js";
5
+
6
+ const MODE_CHANGE_COMMAND_TIMEOUT_MS = 2000;
7
+
8
+ type ModeChangeCommandRunner = (command: string) => void;
9
+ type RunningModeChangeCommand = {
10
+ child: ReturnType<typeof spawn>;
11
+ timeout: ReturnType<typeof setTimeout>;
12
+ };
13
+ export type ModeChangeEvent = { mode: Mode; previousMode: Mode };
14
+
15
+ let activeModeChangeCommand: RunningModeChangeCommand | null = null;
16
+ let pendingModeChangeCommand: string | null = null;
17
+ let modeChangeCommandRunner: ModeChangeCommandRunner = spawnModeChangeCommand;
18
+
19
+ export function setModeChangeCommandRunnerForTests(
20
+ next: ModeChangeCommandRunner,
21
+ ): () => void {
22
+ const prev = modeChangeCommandRunner;
23
+ modeChangeCommandRunner = next;
24
+ return () => {
25
+ modeChangeCommandRunner = prev;
26
+ };
27
+ }
28
+
29
+ function spawnModeChangeCommand(command: string): void {
30
+ if (!command) return;
31
+ if (activeModeChangeCommand) {
32
+ pendingModeChangeCommand = command;
33
+ return;
34
+ }
35
+
36
+ startModeChangeCommand(command);
37
+ }
38
+
39
+ function startModeChangeCommand(command: string): void {
40
+ let child: ReturnType<typeof spawn>;
41
+ try {
42
+ child = spawn(command, {
43
+ shell: true,
44
+ stdio: "ignore",
45
+ windowsHide: true,
46
+ });
47
+ } catch {
48
+ // spawn rejected synchronously (e.g., EMFILE) — never break the editor
49
+ runPendingModeChangeCommand();
50
+ return;
51
+ }
52
+
53
+ let finished = false;
54
+ const finish = () => {
55
+ if (finished) return;
56
+ finished = true;
57
+ clearTimeout(timeout);
58
+ if (activeModeChangeCommand?.child !== child) return;
59
+ activeModeChangeCommand = null;
60
+ runPendingModeChangeCommand();
61
+ };
62
+ const timeout = setTimeout(() => {
63
+ try {
64
+ child.kill();
65
+ } catch {
66
+ // best effort timeout cleanup
67
+ }
68
+ finish();
69
+ }, MODE_CHANGE_COMMAND_TIMEOUT_MS);
70
+ timeout.unref?.();
71
+
72
+ activeModeChangeCommand = { child, timeout };
73
+ child.once("error", finish);
74
+ child.once("close", finish);
75
+ }
76
+
77
+ function runPendingModeChangeCommand(): void {
78
+ const pending = pendingModeChangeCommand;
79
+ pendingModeChangeCommand = null;
80
+ if (pending) startModeChangeCommand(pending);
81
+ }
82
+
83
+ function clearPendingModeChangeCommand(): void {
84
+ pendingModeChangeCommand = null;
85
+ }
86
+
87
+ export function cancelModeChangeCommands(): void {
88
+ pendingModeChangeCommand = null;
89
+ const active = activeModeChangeCommand;
90
+ activeModeChangeCommand = null;
91
+ if (!active) return;
92
+ clearTimeout(active.timeout);
93
+ try {
94
+ active.child.kill();
95
+ } catch {
96
+ // best effort session cleanup
97
+ }
98
+ }
99
+
100
+ export function createModeChangeHandler(
101
+ modeChange: ModeChangeSettings | undefined,
102
+ emitModeChange: (event: ModeChangeEvent) => void,
103
+ ): (mode: Mode, prevMode: Mode) => void {
104
+ const insert = modeChange?.insert;
105
+ const normal = modeChange?.normal;
106
+ return (mode, previousMode) => {
107
+ try {
108
+ emitModeChange({ mode, previousMode });
109
+ } catch {
110
+ // Subscribers must not break editing or configured mode-change commands.
111
+ }
112
+
113
+ const command = mode === "insert" ? insert : normal;
114
+ if (command) {
115
+ modeChangeCommandRunner(command);
116
+ } else {
117
+ clearPendingModeChangeCommand();
118
+ }
119
+ };
120
+ }
package/mode-colors.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { ModeColorSettings } from "./settings.js";
2
+
3
+ const MODE_COLORS = {
4
+ insert: "borderMuted",
5
+ normal: "borderAccent",
6
+ visual: "customMessageLabel",
7
+ ex: "warning",
8
+ } as const;
9
+ const TOKEN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
10
+
11
+ export type ModeColorKey = keyof typeof MODE_COLORS;
12
+ export type ModeColorizers = Record<ModeColorKey, (s: string) => string>;
13
+ export type ThemeLike = { fg(token: string, text: string): string };
14
+
15
+ export function resolveModeColors(
16
+ colors?: ModeColorSettings,
17
+ ): Required<ModeColorSettings> {
18
+ return {
19
+ insert: colors?.insert ?? MODE_COLORS.insert,
20
+ normal: colors?.normal ?? MODE_COLORS.normal,
21
+ visual: colors?.visual ?? MODE_COLORS.visual,
22
+ ex: colors?.ex ?? MODE_COLORS.ex,
23
+ };
24
+ }
25
+
26
+ function colorizeWithTheme(
27
+ theme: ThemeLike,
28
+ token: string,
29
+ fallback: string,
30
+ text: string,
31
+ ): string {
32
+ const trimmedToken = token.trim();
33
+ if (TOKEN.test(trimmedToken)) {
34
+ try {
35
+ return theme.fg(trimmedToken, text);
36
+ } catch {
37
+ return theme.fg(fallback, text);
38
+ }
39
+ }
40
+ return theme.fg(fallback, text);
41
+ }
42
+
43
+ export function buildModeColorizers(
44
+ theme: ThemeLike,
45
+ colors: Required<ModeColorSettings>,
46
+ transform: (text: string) => string = (text) => text,
47
+ ): ModeColorizers {
48
+ const colorizer = (mode: ModeColorKey) => (text: string) =>
49
+ colorizeWithTheme(theme, colors[mode], MODE_COLORS[mode], transform(text));
50
+ return {
51
+ insert: colorizer("insert"),
52
+ normal: colorizer("normal"),
53
+ visual: colorizer("visual"),
54
+ ex: colorizer("ex"),
55
+ };
56
+ }
57
+
58
+ // Sentinel string passed through two border colorizers to compare the color
59
+ // they emit without caring about the text they wrap.
60
+ const BORDER_COLOR_PROBE = "|";
61
+
62
+ // Builds a colorizer that renders Pi's neutral "thinking off" border color.
63
+ // Used as the reference for the resting/default border so pi-vim can recolor
64
+ // only that state and defer to any active thinking level or foreign highlight.
65
+ export function buildOffBorderColor(theme: ThemeLike): (s: string) => string {
66
+ return (s: string) => {
67
+ try {
68
+ return theme.fg("thinkingOff", s);
69
+ } catch {
70
+ // Unknown token (e.g. a stripped-down theme): fall back to the raw text
71
+ // so the equality probe simply won't match a real colored border.
72
+ return s;
73
+ }
74
+ };
75
+ }
76
+
77
+ // Returns true when the host's current border color (`base`) is the neutral
78
+ // resting default, meaning pi-vim may safely apply its own mode color. When
79
+ // `offBorderColor` is unavailable, detection is disabled and this returns true
80
+ // (legacy behavior: always apply the mode color). A probe that throws is
81
+ // treated as "not neutral" so we defer rather than risk clobbering an unknown
82
+ // highlight.
83
+ export function isNeutralBorder(
84
+ base: unknown,
85
+ offBorderColor: ((s: string) => string) | null | undefined,
86
+ ): boolean {
87
+ if (typeof offBorderColor !== "function") return true;
88
+ if (typeof base !== "function") return true;
89
+ try {
90
+ return (
91
+ (base as (s: string) => string)(BORDER_COLOR_PROBE) ===
92
+ offBorderColor(BORDER_COLOR_PROBE)
93
+ );
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
package/mode-label.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { getLineGraphemes } from "./motions.js";
3
+
4
+ // Mode-label fitting for the footer. Pure, grapheme-aware string functions
5
+ // that shrink the ` INSERT `/` NORMAL `/` EX … ` status label to a target
6
+ // width without splitting a grapheme — no editor state. The `render()` path
7
+ // composes the raw label (from editor state) and then fits it here.
8
+
9
+ // Take as many trailing graphemes of `rawLabel` as fit within `width`,
10
+ // measuring by visible width so wide/combining graphemes are never split.
11
+ export function takeModeLabelSuffix(rawLabel: string, width: number): string {
12
+ if (width <= 0) return "";
13
+
14
+ const graphemes = getLineGraphemes(rawLabel);
15
+ const suffix: string[] = [];
16
+ let usedWidth = 0;
17
+
18
+ for (let i = graphemes.length - 1; i >= 0; i--) {
19
+ const grapheme = graphemes[i];
20
+ if (!grapheme) continue;
21
+
22
+ const segment = rawLabel.slice(grapheme.start, grapheme.end);
23
+ const segmentWidth = visibleWidth(segment);
24
+ if (usedWidth + segmentWidth > width) break;
25
+ suffix.push(segment);
26
+ usedWidth += segmentWidth;
27
+ }
28
+
29
+ return suffix.reverse().join("");
30
+ }
31
+
32
+ // Fit `rawLabel` into `width`. When it overflows, keep the leading mode
33
+ // keyword (` INSERT `/` NORMAL `/` EX `) plus an ellipsis and as much of the
34
+ // pending-command tail as fits; fall back to a plain right-truncation when
35
+ // there is no recognizable keyword or no room for the keyword itself.
36
+ export function fitModeLabel(rawLabel: string, width: number): string {
37
+ if (visibleWidth(rawLabel) <= width) return rawLabel;
38
+
39
+ const prefix = rawLabel.startsWith(" INSERT ")
40
+ ? " INSERT "
41
+ : rawLabel.startsWith(" NORMAL ")
42
+ ? " NORMAL "
43
+ : rawLabel.startsWith(" EX ")
44
+ ? " EX "
45
+ : "";
46
+
47
+ if (!prefix || visibleWidth(prefix) >= width) {
48
+ return truncateToWidth(rawLabel, width, "");
49
+ }
50
+
51
+ const suffixWidth = width - visibleWidth(prefix) - 1;
52
+ if (suffixWidth <= 0) return `${prefix}…`;
53
+ return `${prefix}…${takeModeLabelSuffix(rawLabel, suffixWidth)}`;
54
+ }
package/motions.ts ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Motion calculation utilities for vim-mode
3
+ */
4
+
5
+ import type { CharMotion } from "./types.js";
6
+
7
+ // Character types for word boundary detection
8
+ export type WordMotionClass = "word" | "WORD";
9
+
10
+ enum CharType {
11
+ Space = 0,
12
+ Keyword = 1, // alphanumeric + underscore (or all non-space in WORD mode)
13
+ Other = 2, // punctuation/symbols
14
+ }
15
+
16
+ function getCharType(
17
+ c: string | undefined,
18
+ semanticClass: WordMotionClass = "word",
19
+ ): CharType {
20
+ if (!c || /\s/.test(c)) return CharType.Space;
21
+ if (semanticClass === "WORD") return CharType.Keyword;
22
+ if (/\w/.test(c)) return CharType.Keyword;
23
+ return CharType.Other;
24
+ }
25
+
26
+ function clampLineIndex(lines: readonly string[], lineIndex: number): number {
27
+ if (lines.length === 0) return 0;
28
+ if (!Number.isFinite(lineIndex)) return 0;
29
+ const normalized = Math.trunc(lineIndex);
30
+ return Math.max(0, Math.min(normalized, lines.length - 1));
31
+ }
32
+
33
+ /**
34
+ * Column of first non-whitespace char, or 0 for blank lines.
35
+ */
36
+ export function findFirstNonWhitespaceColumn(line: string): number {
37
+ const match = line.search(/\S/);
38
+ return match === -1 ? 0 : match;
39
+ }
40
+
41
+ /**
42
+ * True when line matches ^\s*$.
43
+ */
44
+ export function isBlankLine(line: string | undefined): boolean {
45
+ if (line === undefined) return true;
46
+ return /^\s*$/.test(line);
47
+ }
48
+
49
+ /**
50
+ * Paragraph start: non-blank line at BOF or after a blank line.
51
+ */
52
+ export function isParagraphStart(
53
+ lines: readonly string[],
54
+ lineIndex: number,
55
+ ): boolean {
56
+ if (!Number.isInteger(lineIndex)) return false;
57
+ if (lineIndex < 0 || lineIndex >= lines.length) return false;
58
+ if (isBlankLine(lines[lineIndex])) return false;
59
+ if (lineIndex === 0) return true;
60
+ return isBlankLine(lines[lineIndex - 1]);
61
+ }
62
+
63
+ /**
64
+ * One step of } motion from current line index.
65
+ */
66
+ export function findNextParagraphStart(
67
+ lines: readonly string[],
68
+ fromLine: number,
69
+ ): number {
70
+ if (lines.length === 0) return 0;
71
+
72
+ const start = clampLineIndex(lines, fromLine) + 1;
73
+ for (let i = start; i < lines.length; i++) {
74
+ if (isParagraphStart(lines, i)) return i;
75
+ }
76
+
77
+ return lines.length - 1;
78
+ }
79
+
80
+ /**
81
+ * One step of { motion from current line index.
82
+ */
83
+ export function findPrevParagraphStart(
84
+ lines: readonly string[],
85
+ fromLine: number,
86
+ ): number {
87
+ if (lines.length === 0) return 0;
88
+
89
+ const start = clampLineIndex(lines, fromLine) - 1;
90
+ for (let i = start; i >= 0; i--) {
91
+ if (isParagraphStart(lines, i)) return i;
92
+ }
93
+
94
+ return 0;
95
+ }
96
+
97
+ /**
98
+ * Paragraph motion target for counted { / } semantics.
99
+ */
100
+ export function findParagraphMotionTarget(
101
+ lines: readonly string[],
102
+ fromLine: number,
103
+ direction: "forward" | "backward",
104
+ count: number = 1,
105
+ ): number {
106
+ if (lines.length === 0) return 0;
107
+
108
+ const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
109
+ let currentLine = clampLineIndex(lines, fromLine);
110
+
111
+ for (let i = 0; i < steps; i++) {
112
+ const nextLine =
113
+ direction === "forward"
114
+ ? findNextParagraphStart(lines, currentLine)
115
+ : findPrevParagraphStart(lines, currentLine);
116
+
117
+ if (nextLine === currentLine) break;
118
+ currentLine = nextLine;
119
+ }
120
+
121
+ return currentLine;
122
+ }
123
+
124
+ /**
125
+ * Reverse a character motion direction (f ↔ F, t ↔ T).
126
+ */
127
+ export function reverseCharMotion(motion: CharMotion): CharMotion {
128
+ const reverseMap: Record<CharMotion, CharMotion> = {
129
+ f: "F",
130
+ F: "f",
131
+ t: "T",
132
+ T: "t",
133
+ };
134
+ return reverseMap[motion];
135
+ }
136
+
137
+ const GRAPHEME_SEGMENTER =
138
+ typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
139
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
140
+ : null;
141
+
142
+ export function getLineGraphemes(
143
+ line: string,
144
+ ): Array<{ start: number; end: number }> {
145
+ const segments: Array<{ start: number; end: number }> = [];
146
+ if (GRAPHEME_SEGMENTER) {
147
+ for (const part of GRAPHEME_SEGMENTER.segment(line)) {
148
+ segments.push({
149
+ start: part.index,
150
+ end: part.index + part.segment.length,
151
+ });
152
+ }
153
+ return segments;
154
+ }
155
+ let start = 0;
156
+ for (const text of Array.from(line)) {
157
+ segments.push({ start, end: start + text.length });
158
+ start += text.length;
159
+ }
160
+ return segments;
161
+ }
162
+
163
+ /**
164
+ * Find target column for a character motion (f/F/t/T).
165
+ * @returns target column or null if not found
166
+ */
167
+ export function findCharMotionTarget(
168
+ line: string,
169
+ col: number,
170
+ motion: CharMotion,
171
+ targetChar: string,
172
+ isRepeat: boolean = false,
173
+ count: number = 1,
174
+ ): number | null {
175
+ const isForward = motion === "f" || motion === "t";
176
+ const isTill = motion === "t" || motion === "T";
177
+ const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
178
+
179
+ const graphemes = getLineGraphemes(line);
180
+ let currentIndex = graphemes.findIndex((g) => col < g.end);
181
+ if (currentIndex === -1) currentIndex = graphemes.length;
182
+
183
+ for (let i = 0; i < steps; i++) {
184
+ const isFirst = i === 0;
185
+ const isFinal = i === steps - 1;
186
+ const tillRepeatOffset = isFirst && isTill && isRepeat ? 1 : 0;
187
+
188
+ if (isForward) {
189
+ const nextIndex = currentIndex + 1 + tillRepeatOffset;
190
+ let found = -1;
191
+ for (let j = nextIndex; j < graphemes.length; j++) {
192
+ const g = graphemes[j];
193
+ if (!g) continue;
194
+ // Use startsWith to allow matching base chars if targetChar lacks combining marks,
195
+ // or just exact match since targetChar is typically a full grapheme.
196
+ if (
197
+ line.slice(g.start, g.end) === targetChar ||
198
+ line.slice(g.start, g.end).startsWith(targetChar)
199
+ ) {
200
+ found = j;
201
+ break;
202
+ }
203
+ }
204
+ if (found === -1) return null;
205
+ if (isFinal) {
206
+ const targetGrapheme = graphemes[found];
207
+ if (!targetGrapheme) return null;
208
+ if (!isTill) return targetGrapheme.start;
209
+ const previousGrapheme = graphemes[found - 1];
210
+ return previousGrapheme ? previousGrapheme.start : null;
211
+ }
212
+ currentIndex = found;
213
+ } else {
214
+ const nextIndex = currentIndex - 1 - tillRepeatOffset;
215
+ let found = -1;
216
+ for (let j = nextIndex; j >= 0; j--) {
217
+ const g = graphemes[j];
218
+ if (!g) continue;
219
+ if (
220
+ line.slice(g.start, g.end) === targetChar ||
221
+ line.slice(g.start, g.end).startsWith(targetChar)
222
+ ) {
223
+ found = j;
224
+ break;
225
+ }
226
+ }
227
+ if (found === -1) return null;
228
+ if (isFinal) {
229
+ const targetGrapheme = graphemes[found];
230
+ if (!targetGrapheme) return null;
231
+ if (!isTill) return targetGrapheme.start;
232
+ const afterTarget = graphemes[found + 1];
233
+ return afterTarget ? afterTarget.start : line.length;
234
+ }
235
+ currentIndex = found;
236
+ }
237
+ }
238
+
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Calculate word motion target column.
244
+ */
245
+ export function findWordMotionTarget(
246
+ line: string,
247
+ col: number,
248
+ direction: "forward" | "backward",
249
+ target: "start" | "end",
250
+ semanticClass: WordMotionClass = "word",
251
+ ): number {
252
+ const len = line.length;
253
+ if (len === 0) return 0;
254
+
255
+ let i = Math.max(0, Math.min(col, len));
256
+
257
+ if (direction === "forward") {
258
+ if (i >= len) return len;
259
+
260
+ if (target === "start") {
261
+ // w: move to start of next word
262
+ const startType = getCharType(line[i], semanticClass);
263
+
264
+ // Skip current word/punct block
265
+ if (startType !== CharType.Space) {
266
+ while (i < len && getCharType(line[i], semanticClass) === startType)
267
+ i++;
268
+ }
269
+
270
+ // Skip whitespace
271
+ while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
272
+ i++;
273
+
274
+ return i;
275
+ }
276
+
277
+ // e: move to end of current/next word
278
+ if (i < len - 1) i++;
279
+
280
+ // Skip whitespace forward
281
+ while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
282
+ i++;
283
+
284
+ // Now at start of next word (or end of line). Find end.
285
+ if (i >= len) return len;
286
+
287
+ const type = getCharType(line[i], semanticClass);
288
+ while (i < len - 1 && getCharType(line[i + 1], semanticClass) === type) i++;
289
+
290
+ return i;
291
+ }
292
+
293
+ // b: move to start of previous word
294
+ if (i >= len) i = len - 1;
295
+ if (i > 0) i--;
296
+
297
+ // Skip whitespace backward
298
+ while (i > 0 && getCharType(line[i], semanticClass) === CharType.Space) i--;
299
+
300
+ // Now at end of prev word (or start of line). Find start.
301
+ const type = getCharType(line[i], semanticClass);
302
+ while (i > 0 && getCharType(line[i - 1], semanticClass) === type) i--;
303
+
304
+ return i;
305
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@wwwyo/pi-vim",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.14.2",
4
+ "description": "Vim-style modal editing for Pi's TUI editor",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "vim",
10
+ "editor"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/lajarre/pi-vim.git"
16
+ },
17
+ "files": [
18
+ "*.ts",
19
+ "!*.test.ts",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "build": "echo 'nothing to build'",
25
+ "format": "biome check --write .",
26
+ "lint": "biome check . && eslint .",
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "node --import tsx/esm --test 'test/**/*.test.ts'",
29
+ "test:nvim": "node --import tsx/esm --test 'test/nvim-parity*.ts'",
30
+ "test:e2e": "node --import tsx/esm script/image-attachments-e2e.ts",
31
+ "test:smoke": "node --import tsx/esm script/consumer-smoke.ts",
32
+ "check": "npm run lint && npm run typecheck && npm run test",
33
+ "pack:check": "node --import tsx/esm script/pack-check.ts",
34
+ "scoreboard": "node --import tsx/esm script/scoreboard.ts",
35
+ "hooks:install": "lefthook install",
36
+ "prepublishOnly": "npm run lint && npm run typecheck && npm run pack:check && npm test"
37
+ },
38
+ "pi": {
39
+ "extensions": [
40
+ "./index.ts"
41
+ ]
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "2.4.8",
45
+ "@eslint/js": "^9.25.1",
46
+ "@types/node": "^24.7.2",
47
+ "eslint": "^9.25.1",
48
+ "lefthook": "^2.1.4",
49
+ "tsx": "^4.19.3",
50
+ "typescript": "^5.9.3",
51
+ "typescript-eslint": "^8.31.1"
52
+ },
53
+ "peerDependencies": {
54
+ "@earendil-works/pi-coding-agent": "*",
55
+ "@earendil-works/pi-tui": ">=0.74.0"
56
+ }
57
+ }