@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/LICENSE +21 -0
- package/README.md +552 -2
- package/clipboard-mirror.ts +449 -0
- package/clipboard-policy.ts +23 -0
- package/cursor-shape.ts +138 -0
- package/index.ts +4359 -0
- package/input-keys.ts +50 -0
- package/mode-change-command.ts +120 -0
- package/mode-colors.ts +97 -0
- package/mode-label.ts +54 -0
- package/motions.ts +305 -0
- package/package.json +55 -4
- package/settings.ts +346 -0
- package/text-objects.ts +408 -0
- package/types.ts +50 -0
- package/visual.ts +68 -0
- package/word-boundary-cache.ts +196 -0
package/settings.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type ModeColorSettings = {
|
|
4
|
+
insert?: string;
|
|
5
|
+
normal?: string;
|
|
6
|
+
visual?: string;
|
|
7
|
+
ex?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ModeChangeSettings = {
|
|
11
|
+
insert?: string;
|
|
12
|
+
normal?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ExCommandSettings = {
|
|
16
|
+
piDispatch: boolean;
|
|
17
|
+
copyInputToClipboard: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type InsertEscapeSettings = {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type BorderSyncMode = boolean | "inherit";
|
|
26
|
+
|
|
27
|
+
// Per-surface paint policy for a single mode:
|
|
28
|
+
// - "mode" always paint that mode's color;
|
|
29
|
+
// - "host" always show the host's current border color;
|
|
30
|
+
// - "thinking" show the host color while its border is away from the neutral
|
|
31
|
+
// resting default, otherwise paint the mode color.
|
|
32
|
+
export type SurfaceSync = "mode" | "host" | "thinking";
|
|
33
|
+
export type SurfaceSyncMap = {
|
|
34
|
+
insert: SurfaceSync;
|
|
35
|
+
normal: SurfaceSync;
|
|
36
|
+
visual: SurfaceSync;
|
|
37
|
+
ex: SurfaceSync;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type PiVimSettings = {
|
|
41
|
+
clipboardMirror?: unknown;
|
|
42
|
+
exCommand?: unknown;
|
|
43
|
+
globalExCommand?: unknown;
|
|
44
|
+
insertEscape?: unknown;
|
|
45
|
+
modeColors?: ModeColorSettings;
|
|
46
|
+
modeChange?: ModeChangeSettings;
|
|
47
|
+
// Per-mode paint policy for Pi's input border. Default: every mode "host".
|
|
48
|
+
borderSync?: SurfaceSyncMap;
|
|
49
|
+
// Per-mode paint policy for pi-vim's footer mode label. Default: "mode".
|
|
50
|
+
labelSync?: SurfaceSyncMap;
|
|
51
|
+
// Deprecated, never-released alias superseded by borderSync/labelSync; still
|
|
52
|
+
// accepted and translated in `resolveSurfaceSyncMaps`. `false`/absent → both
|
|
53
|
+
// maps at their defaults; `true` → borderSync all "mode"; the never-released
|
|
54
|
+
// `"inherit"` → both maps all "thinking".
|
|
55
|
+
syncBorderColorWithMode?: BorderSyncMode;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_EX_COMMAND_SETTINGS: ExCommandSettings = {
|
|
59
|
+
piDispatch: true,
|
|
60
|
+
copyInputToClipboard: false,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const DEFAULT_INSERT_ESCAPE_SETTINGS: InsertEscapeSettings = {
|
|
64
|
+
enabled: true,
|
|
65
|
+
timeoutMs: 300,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const M = Symbol(),
|
|
69
|
+
C = ["insert", "normal", "visual", "ex"] as const,
|
|
70
|
+
MC = ["insert", "normal"] as const,
|
|
71
|
+
T = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
72
|
+
const rec = (v: unknown): v is Record<string, unknown> =>
|
|
73
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
74
|
+
|
|
75
|
+
function get(s: unknown, k: keyof PiVimSettings): unknown {
|
|
76
|
+
if (!rec(s) || !Object.hasOwn(s, "piVim")) return M;
|
|
77
|
+
const p = s.piVim;
|
|
78
|
+
if (!rec(p)) return p;
|
|
79
|
+
return Object.hasOwn(p, k) ? p[k] : M;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function colors(v: unknown) {
|
|
83
|
+
if (!rec(v)) return;
|
|
84
|
+
const r: ModeColorSettings = {};
|
|
85
|
+
for (const k of C) {
|
|
86
|
+
const x = v[k],
|
|
87
|
+
t = typeof x === "string" ? x.trim() : "";
|
|
88
|
+
if (T.test(t)) r[k] = t;
|
|
89
|
+
}
|
|
90
|
+
return Object.keys(r)[0] ? r : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const BORDER_SYNC_DEFAULT: SurfaceSync = "host";
|
|
94
|
+
const LABEL_SYNC_DEFAULT: SurfaceSync = "mode";
|
|
95
|
+
|
|
96
|
+
// Validates a per-mode paint-policy map. Like `colors`, a project map is a
|
|
97
|
+
// whole-setting override: missing or invalid entries fall back to the surface
|
|
98
|
+
// default (not to the global map). Returns undefined when no valid entry is
|
|
99
|
+
// present so the caller can defer to the legacy key.
|
|
100
|
+
function surfaceMap(v: unknown, dflt: SurfaceSync): SurfaceSyncMap | undefined {
|
|
101
|
+
if (!rec(v)) return;
|
|
102
|
+
const r: SurfaceSyncMap = {
|
|
103
|
+
insert: dflt,
|
|
104
|
+
normal: dflt,
|
|
105
|
+
visual: dflt,
|
|
106
|
+
ex: dflt,
|
|
107
|
+
};
|
|
108
|
+
let any = false;
|
|
109
|
+
for (const k of C) {
|
|
110
|
+
const x = v[k];
|
|
111
|
+
if (x === "mode" || x === "host" || x === "thinking") {
|
|
112
|
+
r[k] = x;
|
|
113
|
+
any = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return any ? r : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fill(value: SurfaceSync): SurfaceSyncMap {
|
|
120
|
+
return { insert: value, normal: value, visual: value, ex: value };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function modeChange(v: unknown): ModeChangeSettings | undefined {
|
|
124
|
+
if (!rec(v)) return;
|
|
125
|
+
const r: ModeChangeSettings = {};
|
|
126
|
+
for (const k of MC) {
|
|
127
|
+
const x = v[k];
|
|
128
|
+
if (typeof x !== "string") continue;
|
|
129
|
+
const t = x.trim();
|
|
130
|
+
if (t.length > 0) r[k] = t;
|
|
131
|
+
}
|
|
132
|
+
return Object.keys(r)[0] ? r : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function readPiVimClipboardMirrorSetting(g: unknown, p: unknown) {
|
|
136
|
+
let v = get(p, "clipboardMirror");
|
|
137
|
+
if (v !== M) return v;
|
|
138
|
+
v = get(g, "clipboardMirror");
|
|
139
|
+
return v === M ? undefined : v;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function readPiVimExCommandSetting(g: unknown, p: unknown) {
|
|
143
|
+
// The bridge only selects among commands Pi already trusts; it grants no new
|
|
144
|
+
// capability, so a project file may turn it off (unlike modeChange).
|
|
145
|
+
let v = get(p, "exCommand");
|
|
146
|
+
if (v !== M) return v;
|
|
147
|
+
v = get(g, "exCommand");
|
|
148
|
+
return v === M ? undefined : v;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function readPiVimGlobalExCommandSetting(g: unknown, p: unknown) {
|
|
152
|
+
void p;
|
|
153
|
+
// Copying the prompt to the OS clipboard is an exfiltration capability, so
|
|
154
|
+
// only the user-global settings file is trusted. Project settings may be
|
|
155
|
+
// checked into a repo and must not be able to enable clipboard writes.
|
|
156
|
+
const v = get(g, "exCommand");
|
|
157
|
+
return v === M ? undefined : v;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resolveExCommandSettings(
|
|
161
|
+
value: unknown,
|
|
162
|
+
globalValue: unknown,
|
|
163
|
+
): {
|
|
164
|
+
settings: ExCommandSettings;
|
|
165
|
+
warning?: string;
|
|
166
|
+
} {
|
|
167
|
+
const settings = { ...DEFAULT_EX_COMMAND_SETTINGS };
|
|
168
|
+
let invalidObject = false;
|
|
169
|
+
const invalid: string[] = [];
|
|
170
|
+
for (const [k, source] of [
|
|
171
|
+
["piDispatch", value],
|
|
172
|
+
["copyInputToClipboard", globalValue],
|
|
173
|
+
] as const) {
|
|
174
|
+
if (source === undefined) continue;
|
|
175
|
+
if (!rec(source)) {
|
|
176
|
+
invalidObject = true;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!Object.hasOwn(source, k)) continue;
|
|
180
|
+
const v = source[k];
|
|
181
|
+
if (typeof v === "boolean") settings[k] = v;
|
|
182
|
+
else invalid.push(k);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (invalidObject) {
|
|
186
|
+
return {
|
|
187
|
+
settings,
|
|
188
|
+
warning: "Invalid piVim.exCommand; expected an object.",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (!invalid[0]) return { settings };
|
|
192
|
+
return {
|
|
193
|
+
settings,
|
|
194
|
+
warning: `Invalid piVim.exCommand ${invalid.join(", ")}; expected a boolean.`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function readPiVimInsertEscapeSetting(g: unknown, p: unknown) {
|
|
199
|
+
// The escape only deletes text the user just typed in this prompt, so a
|
|
200
|
+
// project file may tune or switch it off (like exCommand.piDispatch).
|
|
201
|
+
let v = get(p, "insertEscape");
|
|
202
|
+
if (v !== M) return v;
|
|
203
|
+
v = get(g, "insertEscape");
|
|
204
|
+
return v === M ? undefined : v;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function resolveInsertEscapeSettings(value: unknown): {
|
|
208
|
+
settings: InsertEscapeSettings;
|
|
209
|
+
warning?: string;
|
|
210
|
+
} {
|
|
211
|
+
const settings = { ...DEFAULT_INSERT_ESCAPE_SETTINGS };
|
|
212
|
+
if (value === undefined) return { settings };
|
|
213
|
+
if (typeof value === "boolean") {
|
|
214
|
+
settings.enabled = value;
|
|
215
|
+
return { settings };
|
|
216
|
+
}
|
|
217
|
+
if (!rec(value)) {
|
|
218
|
+
return {
|
|
219
|
+
settings,
|
|
220
|
+
warning: "Invalid piVim.insertEscape; expected a boolean or an object.",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const invalid: string[] = [];
|
|
224
|
+
if (Object.hasOwn(value, "enabled")) {
|
|
225
|
+
const v = value.enabled;
|
|
226
|
+
if (typeof v === "boolean") settings.enabled = v;
|
|
227
|
+
else invalid.push("enabled");
|
|
228
|
+
}
|
|
229
|
+
if (Object.hasOwn(value, "timeoutMs")) {
|
|
230
|
+
const v = value.timeoutMs;
|
|
231
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= 0)
|
|
232
|
+
settings.timeoutMs = v;
|
|
233
|
+
else invalid.push("timeoutMs");
|
|
234
|
+
}
|
|
235
|
+
if (!invalid[0]) return { settings };
|
|
236
|
+
return {
|
|
237
|
+
settings,
|
|
238
|
+
warning: `Invalid piVim.insertEscape ${invalid.join(", ")}; expected a boolean for enabled and a non-negative finite number for timeoutMs.`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function readPiVimModeColors(g: unknown, p: unknown) {
|
|
243
|
+
const v = get(p, "modeColors");
|
|
244
|
+
// Project settings are a whole-setting override. If a project checks in an
|
|
245
|
+
// invalid modeColors value, fall back to pi-vim defaults instead of leaking a
|
|
246
|
+
// developer's global colors into that project.
|
|
247
|
+
if (v !== M) return colors(v);
|
|
248
|
+
const w = get(g, "modeColors");
|
|
249
|
+
return colors(w);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function readPiVimModeChange(g: unknown, p: unknown) {
|
|
253
|
+
void p;
|
|
254
|
+
// modeChange executes a shell command, so only the user-global settings file
|
|
255
|
+
// is trusted. Project settings may be checked into a repo; treating them as
|
|
256
|
+
// executable hook config would let a checkout run arbitrary commands when the
|
|
257
|
+
// editor changes mode.
|
|
258
|
+
const v = get(g, "modeChange");
|
|
259
|
+
return modeChange(v);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function readPiVimBorderSyncSetting(
|
|
263
|
+
g: unknown,
|
|
264
|
+
p: unknown,
|
|
265
|
+
): BorderSyncMode | undefined {
|
|
266
|
+
const read = (v: unknown): BorderSyncMode | undefined =>
|
|
267
|
+
v === "inherit" || v === true || v === false ? v : undefined;
|
|
268
|
+
const v = get(p, "syncBorderColorWithMode");
|
|
269
|
+
if (v !== M) return read(v);
|
|
270
|
+
return read(get(g, "syncBorderColorWithMode"));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function readPiVimBorderSync(
|
|
274
|
+
g: unknown,
|
|
275
|
+
p: unknown,
|
|
276
|
+
): SurfaceSyncMap | undefined {
|
|
277
|
+
// Project settings are a whole-setting override (as with modeColors): a
|
|
278
|
+
// present project value wins even if invalid, so its global counterpart never
|
|
279
|
+
// leaks through.
|
|
280
|
+
const v = get(p, "borderSync");
|
|
281
|
+
if (v !== M) return surfaceMap(v, BORDER_SYNC_DEFAULT);
|
|
282
|
+
return surfaceMap(get(g, "borderSync"), BORDER_SYNC_DEFAULT);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function readPiVimLabelSync(
|
|
286
|
+
g: unknown,
|
|
287
|
+
p: unknown,
|
|
288
|
+
): SurfaceSyncMap | undefined {
|
|
289
|
+
const v = get(p, "labelSync");
|
|
290
|
+
if (v !== M) return surfaceMap(v, LABEL_SYNC_DEFAULT);
|
|
291
|
+
return surfaceMap(get(g, "labelSync"), LABEL_SYNC_DEFAULT);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Collapses the two new per-mode maps and the deprecated legacy key into the
|
|
295
|
+
// two full maps pi-vim paints with. A present new map wins over the legacy key
|
|
296
|
+
// for its surface; otherwise the legacy value is translated:
|
|
297
|
+
// - `true` → borderSync all "mode" (label stays default "mode")
|
|
298
|
+
// - `"inherit"` → borderSync/labelSync all "thinking"
|
|
299
|
+
// - `false`/absent → both maps at their defaults ("host" / "mode")
|
|
300
|
+
export function resolveSurfaceSyncMaps(settings: {
|
|
301
|
+
borderSync?: SurfaceSyncMap;
|
|
302
|
+
labelSync?: SurfaceSyncMap;
|
|
303
|
+
syncBorderColorWithMode?: BorderSyncMode;
|
|
304
|
+
}): { borderSync: SurfaceSyncMap; labelSync: SurfaceSyncMap } {
|
|
305
|
+
const legacy = settings.syncBorderColorWithMode;
|
|
306
|
+
const borderSync =
|
|
307
|
+
settings.borderSync ??
|
|
308
|
+
(legacy === true
|
|
309
|
+
? fill("mode")
|
|
310
|
+
: legacy === "inherit"
|
|
311
|
+
? fill("thinking")
|
|
312
|
+
: fill(BORDER_SYNC_DEFAULT));
|
|
313
|
+
const labelSync =
|
|
314
|
+
settings.labelSync ??
|
|
315
|
+
(legacy === "inherit" ? fill("thinking") : fill(LABEL_SYNC_DEFAULT));
|
|
316
|
+
return { borderSync, labelSync };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function disk(cwd: string): PiVimSettings {
|
|
320
|
+
const s = SettingsManager.create(cwd),
|
|
321
|
+
g = s.getGlobalSettings(),
|
|
322
|
+
p = s.getProjectSettings();
|
|
323
|
+
return {
|
|
324
|
+
clipboardMirror: readPiVimClipboardMirrorSetting(g, p),
|
|
325
|
+
exCommand: readPiVimExCommandSetting(g, p),
|
|
326
|
+
globalExCommand: readPiVimGlobalExCommandSetting(g, p),
|
|
327
|
+
insertEscape: readPiVimInsertEscapeSetting(g, p),
|
|
328
|
+
modeColors: readPiVimModeColors(g, p),
|
|
329
|
+
modeChange: readPiVimModeChange(g, p),
|
|
330
|
+
borderSync: readPiVimBorderSync(g, p),
|
|
331
|
+
labelSync: readPiVimLabelSync(g, p),
|
|
332
|
+
syncBorderColorWithMode: readPiVimBorderSyncSetting(g, p),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let reader = disk;
|
|
337
|
+
export function readPiVimSettings(cwd: string) {
|
|
338
|
+
return reader(cwd);
|
|
339
|
+
}
|
|
340
|
+
export function setPiVimSettingsReaderForTests(next: typeof disk) {
|
|
341
|
+
const prev = reader;
|
|
342
|
+
reader = next;
|
|
343
|
+
return () => {
|
|
344
|
+
reader = prev;
|
|
345
|
+
};
|
|
346
|
+
}
|