@aemonge-dev/pi-stardeck 0.0.0-stage → 0.6.0

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -2
  3. package/demo/pi-stardeck.tape +59 -0
  4. package/docs/architecture.md +106 -0
  5. package/docs/assets/pi-stardeck.gif +0 -0
  6. package/docs/development.md +128 -0
  7. package/licenses/README.md +19 -0
  8. package/licenses/pi-vim-LICENSE +21 -0
  9. package/package.json +81 -4
  10. package/packages/footer/README.md +15 -0
  11. package/packages/footer/index.ts +37 -0
  12. package/packages/footer/package.json +32 -0
  13. package/packages/footer/src/protocol.ts +10 -0
  14. package/packages/footer/src/quota.ts +26 -0
  15. package/packages/footer/src/telemetry.ts +59 -0
  16. package/packages/pi-stardeck/README.md +220 -0
  17. package/packages/pi-stardeck/index.ts +691 -0
  18. package/packages/pi-stardeck/package.json +32 -0
  19. package/packages/pi-stardeck/src/activity-age.ts +184 -0
  20. package/packages/pi-stardeck/src/capabilities.ts +105 -0
  21. package/packages/pi-stardeck/src/deck.ts +807 -0
  22. package/packages/pi-stardeck/src/gauge.ts +1323 -0
  23. package/packages/pi-stardeck/src/git-refresh.ts +5 -0
  24. package/packages/pi-stardeck/src/process-resources.ts +428 -0
  25. package/packages/pi-stardeck/src/title-state.ts +195 -0
  26. package/packages/pi-stardeck/src/title.ts +443 -0
  27. package/packages/pi-vim-top-border/LICENSE +21 -0
  28. package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
  29. package/packages/pi-vim-top-border/README.md +577 -0
  30. package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
  31. package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
  32. package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
  33. package/packages/pi-vim-top-border/index.ts +4195 -0
  34. package/packages/pi-vim-top-border/input-keys.ts +49 -0
  35. package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
  36. package/packages/pi-vim-top-border/mode-colors.ts +97 -0
  37. package/packages/pi-vim-top-border/mode-label.ts +62 -0
  38. package/packages/pi-vim-top-border/motions.ts +297 -0
  39. package/packages/pi-vim-top-border/package.json +47 -0
  40. package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
  41. package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
  42. package/packages/pi-vim-top-border/prompt-status.ts +131 -0
  43. package/packages/pi-vim-top-border/settings.ts +287 -0
  44. package/packages/pi-vim-top-border/text-objects.ts +389 -0
  45. package/packages/pi-vim-top-border/types.ts +51 -0
  46. package/packages/pi-vim-top-border/visual.ts +65 -0
  47. package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
  48. package/packages/status/README.md +398 -0
  49. package/packages/status/index.ts +1717 -0
  50. package/packages/status/package.json +33 -0
  51. package/packages/status/src/active-work.ts +235 -0
  52. package/packages/status/src/config.ts +218 -0
  53. package/packages/status/src/diagnostics.ts +238 -0
  54. package/packages/status/src/goal-block.ts +377 -0
  55. package/packages/status/src/goal.ts +170 -0
  56. package/packages/status/src/ledger.ts +101 -0
  57. package/packages/status/src/openspec.ts +394 -0
  58. package/packages/status/src/orchestration.ts +237 -0
  59. package/packages/status/src/publisher.ts +1294 -0
  60. package/packages/status/src/reducer.ts +190 -0
  61. package/packages/status/src/runtime-runs.ts +267 -0
  62. package/packages/status/src/state.ts +178 -0
  63. package/packages/status/src/title-activity.ts +224 -0
  64. package/packages/status/src/title-state.ts +218 -0
  65. package/packages/status/src/turn-cost.ts +63 -0
  66. package/packages/status/src/types.ts +228 -0
  67. package/src/deck-surface.ts +46 -0
  68. package/src/health.ts +227 -0
  69. package/src/index.ts +27 -0
  70. package/src/pi-status.ts +334 -0
@@ -0,0 +1,287 @@
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 BorderSyncMode = boolean | 'inherit';
21
+
22
+ // Per-surface paint policy for a single mode:
23
+ // - "mode" always paint that mode's color;
24
+ // - "host" always show the host's current border color;
25
+ // - "thinking" show the host color while its border is away from the neutral
26
+ // resting default, otherwise paint the mode color.
27
+ export type SurfaceSync = 'mode' | 'host' | 'thinking';
28
+ export type SurfaceSyncMap = {
29
+ insert: SurfaceSync;
30
+ normal: SurfaceSync;
31
+ visual: SurfaceSync;
32
+ ex: SurfaceSync;
33
+ };
34
+
35
+ export type PiVimSettings = {
36
+ clipboardMirror?: unknown;
37
+ exCommand?: unknown;
38
+ globalExCommand?: unknown;
39
+ modeColors?: ModeColorSettings;
40
+ modeChange?: ModeChangeSettings;
41
+ // Per-mode paint policy for Pi's input border. Default: every mode "host".
42
+ borderSync?: SurfaceSyncMap;
43
+ // Per-mode paint policy for pi-vim's footer mode label. Default: "mode".
44
+ labelSync?: SurfaceSyncMap;
45
+ // Deprecated, never-released alias superseded by borderSync/labelSync; still
46
+ // accepted and translated in `resolveSurfaceSyncMaps`. `false`/absent → both
47
+ // maps at their defaults; `true` → borderSync all "mode"; the never-released
48
+ // `"inherit"` → both maps all "thinking".
49
+ syncBorderColorWithMode?: BorderSyncMode;
50
+ };
51
+
52
+ export const DEFAULT_EX_COMMAND_SETTINGS: ExCommandSettings = {
53
+ piDispatch: true,
54
+ copyInputToClipboard: false,
55
+ };
56
+
57
+ const M = Symbol(),
58
+ C = ['insert', 'normal', 'visual', 'ex'] as const,
59
+ MC = ['insert', 'normal'] as const,
60
+ T = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
61
+ const rec = (v: unknown): v is Record<string, unknown> =>
62
+ typeof v === 'object' && v !== null && !Array.isArray(v);
63
+
64
+ function get(s: unknown, k: keyof PiVimSettings): unknown {
65
+ if (!rec(s) || !Object.hasOwn(s, 'piVim')) return M;
66
+ const p = s.piVim;
67
+ if (!rec(p)) return p;
68
+ return Object.hasOwn(p, k) ? p[k] : M;
69
+ }
70
+
71
+ function colors(v: unknown) {
72
+ if (!rec(v)) return;
73
+ const r: ModeColorSettings = {};
74
+ for (const k of C) {
75
+ const x = v[k],
76
+ t = typeof x === 'string' ? x.trim() : '';
77
+ if (T.test(t)) r[k] = t;
78
+ }
79
+ return Object.keys(r)[0] ? r : undefined;
80
+ }
81
+
82
+ const BORDER_SYNC_DEFAULT: SurfaceSync = 'host';
83
+ const LABEL_SYNC_DEFAULT: SurfaceSync = 'mode';
84
+
85
+ // Validates a per-mode paint-policy map. Like `colors`, a project map is a
86
+ // whole-setting override: missing or invalid entries fall back to the surface
87
+ // default (not to the global map). Returns undefined when no valid entry is
88
+ // present so the caller can defer to the legacy key.
89
+ function surfaceMap(v: unknown, dflt: SurfaceSync): SurfaceSyncMap | undefined {
90
+ if (!rec(v)) return;
91
+ const r: SurfaceSyncMap = {
92
+ insert: dflt,
93
+ normal: dflt,
94
+ visual: dflt,
95
+ ex: dflt,
96
+ };
97
+ let any = false;
98
+ for (const k of C) {
99
+ const x = v[k];
100
+ if (x === 'mode' || x === 'host' || x === 'thinking') {
101
+ r[k] = x;
102
+ any = true;
103
+ }
104
+ }
105
+ return any ? r : undefined;
106
+ }
107
+
108
+ function fill(value: SurfaceSync): SurfaceSyncMap {
109
+ return { insert: value, normal: value, visual: value, ex: value };
110
+ }
111
+
112
+ function modeChange(v: unknown): ModeChangeSettings | undefined {
113
+ if (!rec(v)) return;
114
+ const r: ModeChangeSettings = {};
115
+ for (const k of MC) {
116
+ const x = v[k];
117
+ if (typeof x !== 'string') continue;
118
+ const t = x.trim();
119
+ if (t.length > 0) r[k] = t;
120
+ }
121
+ return Object.keys(r)[0] ? r : undefined;
122
+ }
123
+
124
+ export function readPiVimClipboardMirrorSetting(g: unknown, p: unknown) {
125
+ let v = get(p, 'clipboardMirror');
126
+ if (v !== M) return v;
127
+ v = get(g, 'clipboardMirror');
128
+ return v === M ? undefined : v;
129
+ }
130
+
131
+ export function readPiVimExCommandSetting(g: unknown, p: unknown) {
132
+ // The bridge only selects among commands Pi already trusts; it grants no new
133
+ // capability, so a project file may turn it off (unlike modeChange).
134
+ let v = get(p, 'exCommand');
135
+ if (v !== M) return v;
136
+ v = get(g, 'exCommand');
137
+ return v === M ? undefined : v;
138
+ }
139
+
140
+ export function readPiVimGlobalExCommandSetting(g: unknown, p: unknown) {
141
+ void p;
142
+ // Copying the prompt to the OS clipboard is an exfiltration capability, so
143
+ // only the user-global settings file is trusted. Project settings may be
144
+ // checked into a repo and must not be able to enable clipboard writes.
145
+ const v = get(g, 'exCommand');
146
+ return v === M ? undefined : v;
147
+ }
148
+
149
+ export function resolveExCommandSettings(
150
+ value: unknown,
151
+ globalValue: unknown,
152
+ ): {
153
+ settings: ExCommandSettings;
154
+ warning?: string;
155
+ } {
156
+ const settings = { ...DEFAULT_EX_COMMAND_SETTINGS };
157
+ let invalidObject = false;
158
+ const invalid: string[] = [];
159
+ for (const [k, source] of [
160
+ ['piDispatch', value],
161
+ ['copyInputToClipboard', globalValue],
162
+ ] as const) {
163
+ if (source === undefined) continue;
164
+ if (!rec(source)) {
165
+ invalidObject = true;
166
+ continue;
167
+ }
168
+ if (!Object.hasOwn(source, k)) continue;
169
+ const v = source[k];
170
+ if (typeof v === 'boolean') settings[k] = v;
171
+ else invalid.push(k);
172
+ }
173
+
174
+ if (invalidObject) {
175
+ return {
176
+ settings,
177
+ warning: 'Invalid piVim.exCommand; expected an object.',
178
+ };
179
+ }
180
+ if (!invalid[0]) return { settings };
181
+ return {
182
+ settings,
183
+ warning: `Invalid piVim.exCommand ${invalid.join(', ')}; expected a boolean.`,
184
+ };
185
+ }
186
+
187
+ export function readPiVimModeColors(g: unknown, p: unknown) {
188
+ const v = get(p, 'modeColors');
189
+ // Project settings are a whole-setting override. If a project checks in an
190
+ // invalid modeColors value, fall back to pi-vim defaults instead of leaking a
191
+ // developer's global colors into that project.
192
+ if (v !== M) return colors(v);
193
+ const w = get(g, 'modeColors');
194
+ return colors(w);
195
+ }
196
+
197
+ export function readPiVimModeChange(g: unknown, p: unknown) {
198
+ void p;
199
+ // modeChange executes a shell command, so only the user-global settings file
200
+ // is trusted. Project settings may be checked into a repo; treating them as
201
+ // executable hook config would let a checkout run arbitrary commands when the
202
+ // editor changes mode.
203
+ const v = get(g, 'modeChange');
204
+ return modeChange(v);
205
+ }
206
+
207
+ export function readPiVimBorderSyncSetting(
208
+ g: unknown,
209
+ p: unknown,
210
+ ): BorderSyncMode | undefined {
211
+ const read = (v: unknown): BorderSyncMode | undefined =>
212
+ v === 'inherit' || v === true || v === false ? v : undefined;
213
+ const v = get(p, 'syncBorderColorWithMode');
214
+ if (v !== M) return read(v);
215
+ return read(get(g, 'syncBorderColorWithMode'));
216
+ }
217
+
218
+ export function readPiVimBorderSync(
219
+ g: unknown,
220
+ p: unknown,
221
+ ): SurfaceSyncMap | undefined {
222
+ // Project settings are a whole-setting override (as with modeColors): a
223
+ // present project value wins even if invalid, so its global counterpart never
224
+ // leaks through.
225
+ const v = get(p, 'borderSync');
226
+ if (v !== M) return surfaceMap(v, BORDER_SYNC_DEFAULT);
227
+ return surfaceMap(get(g, 'borderSync'), BORDER_SYNC_DEFAULT);
228
+ }
229
+
230
+ export function readPiVimLabelSync(g: unknown, p: unknown): SurfaceSyncMap | undefined {
231
+ const v = get(p, 'labelSync');
232
+ if (v !== M) return surfaceMap(v, LABEL_SYNC_DEFAULT);
233
+ return surfaceMap(get(g, 'labelSync'), LABEL_SYNC_DEFAULT);
234
+ }
235
+
236
+ // Collapses the two new per-mode maps and the deprecated legacy key into the
237
+ // two full maps pi-vim paints with. A present new map wins over the legacy key
238
+ // for its surface; otherwise the legacy value is translated:
239
+ // - `true` → borderSync all "mode" (label stays default "mode")
240
+ // - `"inherit"` → borderSync/labelSync all "thinking"
241
+ // - `false`/absent → both maps at their defaults ("host" / "mode")
242
+ export function resolveSurfaceSyncMaps(settings: {
243
+ borderSync?: SurfaceSyncMap;
244
+ labelSync?: SurfaceSyncMap;
245
+ syncBorderColorWithMode?: BorderSyncMode;
246
+ }): { borderSync: SurfaceSyncMap; labelSync: SurfaceSyncMap } {
247
+ const legacy = settings.syncBorderColorWithMode;
248
+ const borderSync =
249
+ settings.borderSync ??
250
+ (legacy === true
251
+ ? fill('mode')
252
+ : legacy === 'inherit'
253
+ ? fill('thinking')
254
+ : fill(BORDER_SYNC_DEFAULT));
255
+ const labelSync =
256
+ settings.labelSync ??
257
+ (legacy === 'inherit' ? fill('thinking') : fill(LABEL_SYNC_DEFAULT));
258
+ return { borderSync, labelSync };
259
+ }
260
+
261
+ function disk(cwd: string): PiVimSettings {
262
+ const s = SettingsManager.create(cwd),
263
+ g = s.getGlobalSettings(),
264
+ p = s.getProjectSettings();
265
+ return {
266
+ clipboardMirror: readPiVimClipboardMirrorSetting(g, p),
267
+ exCommand: readPiVimExCommandSetting(g, p),
268
+ globalExCommand: readPiVimGlobalExCommandSetting(g, p),
269
+ modeColors: readPiVimModeColors(g, p),
270
+ modeChange: readPiVimModeChange(g, p),
271
+ borderSync: readPiVimBorderSync(g, p),
272
+ labelSync: readPiVimLabelSync(g, p),
273
+ syncBorderColorWithMode: readPiVimBorderSyncSetting(g, p),
274
+ };
275
+ }
276
+
277
+ let reader = disk;
278
+ export function readPiVimSettings(cwd: string) {
279
+ return reader(cwd);
280
+ }
281
+ export function setPiVimSettingsReaderForTests(next: typeof disk) {
282
+ const prev = reader;
283
+ reader = next;
284
+ return () => {
285
+ reader = prev;
286
+ };
287
+ }
@@ -0,0 +1,389 @@
1
+ import { getLineGraphemes } from './motions.js';
2
+
3
+ export type TextObjectKind = 'i' | 'a';
4
+
5
+ export type TextObjectRange = {
6
+ startAbs: number;
7
+ endAbs: number;
8
+ };
9
+
10
+ export type WordTextObjectClass = 'word' | 'WORD';
11
+
12
+ export type DelimiterSpec = {
13
+ type: 'quote' | 'bracket';
14
+ open: string;
15
+ close: string;
16
+ };
17
+
18
+ export type MatchingPairKind = '()' | '[]' | '{}';
19
+
20
+ export type MatchingPairMotionTarget = {
21
+ pair: MatchingPairKind;
22
+ sourceAbs: number;
23
+ targetAbs: number;
24
+ rangeAnchorAbs: number;
25
+ };
26
+
27
+ function normalizeCount(count: number): number {
28
+ if (!Number.isFinite(count) || count < 1) return 1;
29
+ return Math.floor(count);
30
+ }
31
+
32
+ function clampCursorCol(line: string, cursorCol: number): number {
33
+ if (line.length === 0) return 0;
34
+ if (!Number.isFinite(cursorCol)) return 0;
35
+
36
+ const normalized = Math.trunc(cursorCol);
37
+ return Math.max(0, Math.min(normalized, line.length - 1));
38
+ }
39
+
40
+ function clampCursorAbs(text: string, cursorAbs: number): number {
41
+ if (text.length === 0) return 0;
42
+ if (!Number.isFinite(cursorAbs)) return 0;
43
+
44
+ const normalized = Math.trunc(cursorAbs);
45
+ return Math.max(0, Math.min(normalized, text.length - 1));
46
+ }
47
+
48
+ function findLogicalLineBounds(
49
+ line: string,
50
+ cursorCol: number,
51
+ ): { start: number; end: number } {
52
+ if (line.length === 0) return { start: 0, end: 0 };
53
+
54
+ const previousSearchStart = line[cursorCol] === '\n' ? cursorCol - 1 : cursorCol;
55
+ const start = line.lastIndexOf('\n', previousSearchStart) + 1;
56
+ const nextNewline = line.indexOf('\n', cursorCol);
57
+
58
+ return { start, end: nextNewline === -1 ? line.length : nextNewline };
59
+ }
60
+
61
+ /**
62
+ * Vim character classes for word text objects: 0 = blank, 1 = other
63
+ * (non-blank punctuation/symbol), 2 = word (keyword). `iW`/`aW` collapse
64
+ * every non-blank character into the single word class. Matches nvim's
65
+ * `cls()` split, which is what `iw`/`aw` scan over.
66
+ */
67
+ type WordCharClass = 0 | 1 | 2;
68
+
69
+ // Unicode-aware keyword test: letters, numbers, combining marks (so grapheme
70
+ // clusters such as a base letter plus combining accent stay in one word), and
71
+ // underscore. Mirrors nvim treating accented Latin and CJK as word characters,
72
+ // unlike the ASCII-only `\w`.
73
+ const WORD_CHAR_PATTERN = /[\p{L}\p{N}\p{M}_]/u;
74
+
75
+ function isWordChar(ch: string): boolean {
76
+ return WORD_CHAR_PATTERN.test(ch);
77
+ }
78
+
79
+ function pairKind(ch?: string): MatchingPairKind | null {
80
+ return ch === '(' || ch === ')'
81
+ ? '()'
82
+ : ch === '[' || ch === ']'
83
+ ? '[]'
84
+ : ch === '{' || ch === '}'
85
+ ? '{}'
86
+ : null;
87
+ }
88
+
89
+ function scanSameDelimiterPairs(
90
+ text: string,
91
+ open: string,
92
+ close: string,
93
+ onPair: (openAbs: number, closeAbs: number) => number | null,
94
+ ): number | null {
95
+ const stack: number[] = [];
96
+ for (let index = 0; index < text.length; index++) {
97
+ if (text[index] === open) stack.push(index);
98
+ else if (text[index] === close) {
99
+ const openAbs = stack.pop();
100
+ if (openAbs === undefined) continue;
101
+ const targetAbs = onPair(openAbs, index);
102
+ if (targetAbs !== null) return targetAbs;
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export function normalizeDelimiterKey(key: string): DelimiterSpec | null {
109
+ if (key === '"' || key === "'" || key === '`')
110
+ return { type: 'quote', open: key, close: key };
111
+ const pair =
112
+ key === '(' || key === ')' || key === 'b'
113
+ ? '()'
114
+ : key === '[' || key === ']'
115
+ ? '[]'
116
+ : key === '{' || key === '}' || key === 'B'
117
+ ? '{}'
118
+ : null;
119
+ return pair ? { type: 'bracket', open: pair[0], close: pair[1] } : null;
120
+ }
121
+
122
+ export function isEscapedDelimiter(text: string, index: number): boolean {
123
+ if (!Number.isInteger(index) || index <= 0 || index >= text.length) return false;
124
+
125
+ let backslashCount = 0;
126
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
127
+ backslashCount++;
128
+ }
129
+
130
+ return backslashCount % 2 === 1;
131
+ }
132
+
133
+ export function resolveQuoteObjectRange(
134
+ text: string,
135
+ cursorAbs: number,
136
+ kind: TextObjectKind,
137
+ quote: string,
138
+ ): TextObjectRange | null {
139
+ const spec = normalizeDelimiterKey(quote);
140
+ if (spec?.type !== 'quote') return null;
141
+
142
+ const cursor = clampCursorAbs(text, cursorAbs);
143
+ const bounds = findLogicalLineBounds(text, cursor);
144
+ if (bounds.start >= bounds.end) return null;
145
+
146
+ let openIndex: number | null = null;
147
+ let bestPair: { open: number; close: number } | null = null;
148
+
149
+ for (let index = bounds.start; index < bounds.end; index++) {
150
+ if (text[index] !== quote || isEscapedDelimiter(text, index)) continue;
151
+
152
+ if (openIndex === null) {
153
+ openIndex = index;
154
+ continue;
155
+ }
156
+
157
+ if (openIndex <= cursor && cursor <= index) {
158
+ if (bestPair === null || index - openIndex < bestPair.close - bestPair.open) {
159
+ bestPair = { open: openIndex, close: index };
160
+ }
161
+ }
162
+ openIndex = null;
163
+ }
164
+
165
+ if (bestPair === null) return null;
166
+
167
+ if (kind === 'i') {
168
+ return {
169
+ startAbs: bestPair.open + 1,
170
+ endAbs: bestPair.close,
171
+ };
172
+ }
173
+
174
+ return {
175
+ startAbs: bestPair.open,
176
+ endAbs: bestPair.close + 1,
177
+ };
178
+ }
179
+
180
+ export function resolveBracketObjectRange(
181
+ text: string,
182
+ cursorAbs: number,
183
+ kind: TextObjectKind,
184
+ open: string,
185
+ close: string,
186
+ ): TextObjectRange | null {
187
+ if (open.length !== 1 || close.length !== 1 || open === close) return null;
188
+
189
+ const cursor = clampCursorAbs(text, cursorAbs);
190
+ let bestPair = null as { open: number; close: number } | null;
191
+
192
+ scanSameDelimiterPairs(text, open, close, (openIndex, closeIndex) => {
193
+ if (openIndex <= cursor && cursor <= closeIndex) {
194
+ if (
195
+ bestPair === null ||
196
+ closeIndex - openIndex < bestPair.close - bestPair.open
197
+ ) {
198
+ bestPair = { open: openIndex, close: closeIndex };
199
+ }
200
+ }
201
+ return null;
202
+ });
203
+
204
+ if (bestPair === null) return null;
205
+
206
+ if (kind === 'i') {
207
+ return {
208
+ startAbs: bestPair.open + 1,
209
+ endAbs: bestPair.close,
210
+ };
211
+ }
212
+
213
+ return {
214
+ startAbs: bestPair.open,
215
+ endAbs: bestPair.close + 1,
216
+ };
217
+ }
218
+
219
+ export function resolveMatchingPairMotionTarget(
220
+ text: string,
221
+ cursorAbs: number,
222
+ currentLineStartAbs: number,
223
+ currentLineEndAbs: number,
224
+ ): MatchingPairMotionTarget | null {
225
+ const start = currentLineStartAbs,
226
+ end = currentLineEndAbs;
227
+ if (!text.length || start >= end) return null;
228
+ const visibleEol = cursorAbs >= end;
229
+ let sourceAbs = visibleEol ? end - 1 : Math.max(cursorAbs, start);
230
+ const rangeAnchorAbs = visibleEol ? sourceAbs : cursorAbs;
231
+ let pair = pairKind(text[sourceAbs]);
232
+ for (let index = sourceAbs + 1; !visibleEol && !pair && index < end; index++) {
233
+ pair = pairKind(text[index]);
234
+ if (pair) sourceAbs = index;
235
+ }
236
+ if (!pair) return null;
237
+ const targetAbs = scanSameDelimiterPairs(
238
+ text,
239
+ pair[0],
240
+ pair[1],
241
+ (openAbs, closeAbs) =>
242
+ openAbs === sourceAbs ? closeAbs : closeAbs === sourceAbs ? openAbs : null,
243
+ );
244
+ if (targetAbs !== null) return { pair, sourceAbs, targetAbs, rangeAnchorAbs };
245
+
246
+ return null;
247
+ }
248
+
249
+ export function resolveDelimitedTextObjectRange(
250
+ text: string,
251
+ cursorAbs: number,
252
+ kind: TextObjectKind,
253
+ key: string,
254
+ ): TextObjectRange | null {
255
+ const spec = normalizeDelimiterKey(key);
256
+ if (spec === null) return null;
257
+
258
+ if (spec.type === 'quote') {
259
+ return resolveQuoteObjectRange(text, cursorAbs, kind, spec.open);
260
+ }
261
+
262
+ if (spec.type === 'bracket') {
263
+ return resolveBracketObjectRange(text, cursorAbs, kind, spec.open, spec.close);
264
+ }
265
+
266
+ return null;
267
+ }
268
+
269
+ export function resolveWordTextObjectRange(
270
+ line: string,
271
+ lineStartAbs: number,
272
+ cursorCol: number,
273
+ kind: TextObjectKind,
274
+ count: number = 1,
275
+ semanticClass: WordTextObjectClass = 'word',
276
+ ): TextObjectRange | null {
277
+ if (line.length === 0) return null;
278
+
279
+ const cursor = clampCursorCol(line, cursorCol);
280
+ const bounds = findLogicalLineBounds(line, cursor);
281
+ if (bounds.start >= bounds.end) return null;
282
+
283
+ // Classify by grapheme (keyed on the grapheme's first code point), not by a
284
+ // single UTF-16 code unit. Indexing `line[idx]` splits astral letters — a
285
+ // lone surrogate half fails the `\p{L}` keyword test and scores as
286
+ // punctuation (`a𐐀b` becomes three runs) — and detaches a base character
287
+ // from its trailing combining marks, so an emoji plus variation selector
288
+ // (`☕️`) breaks into a symbol run and a mark run. nvim keeps each grapheme in
289
+ // one class run; classifying the grapheme's leading code point and stamping
290
+ // that class across the grapheme's UTF-16 offsets reproduces that while the
291
+ // returned ranges stay in UTF-16 offsets.
292
+ const classByOffset = new Int8Array(line.length).fill(-1);
293
+ for (const { start: gStart, end: gEnd } of getLineGraphemes(line)) {
294
+ const codePoint = line.codePointAt(gStart);
295
+ const ch = codePoint === undefined ? '' : String.fromCodePoint(codePoint);
296
+ // A newline (only ever its own grapheme) stays a -1 boundary sentinel.
297
+ if (ch === '' || ch === '\n') continue;
298
+ const cls: WordCharClass = /\s/.test(ch)
299
+ ? 0
300
+ : semanticClass === 'WORD'
301
+ ? 2
302
+ : isWordChar(ch)
303
+ ? 2
304
+ : 1;
305
+ classByOffset.fill(cls, gStart, gEnd);
306
+ }
307
+
308
+ // Class of the grapheme covering idx within the current logical line, or null
309
+ // when idx falls outside the line or lands on a newline (a boundary).
310
+ const classAt = (idx: number): WordCharClass | null => {
311
+ if (idx < bounds.start || idx >= bounds.end) return null;
312
+ const cls = classByOffset[idx];
313
+ return cls === undefined || cls < 0 ? null : (cls as WordCharClass);
314
+ };
315
+
316
+ // Extend `end` over the maximal run of the class starting at `end`.
317
+ const extendRun = (end: number): number => {
318
+ const runClass = classAt(end);
319
+ if (runClass === null) return end;
320
+ let next = end;
321
+ while (next < bounds.end && classAt(next) === runClass) next++;
322
+ return next;
323
+ };
324
+
325
+ const totalCount = normalizeCount(count);
326
+ const cursorCol0 = Math.max(bounds.start, Math.min(cursor, bounds.end - 1));
327
+ const cursorClass = classAt(cursorCol0);
328
+ if (cursorClass === null) return null;
329
+
330
+ // The run under the cursor, whatever its class (word, punctuation, or the
331
+ // whitespace run itself — nvim's `iw`/`aw` select whichever the cursor is on).
332
+ let start = cursorCol0;
333
+ while (start > bounds.start && classAt(start - 1) === cursorClass) start--;
334
+ let end = cursorCol0 + 1;
335
+ while (end < bounds.end && classAt(end) === cursorClass) end++;
336
+
337
+ if (kind === 'i') {
338
+ // `count` consecutive runs (whitespace runs are counted too), forward.
339
+ let remaining = totalCount - 1;
340
+ while (remaining > 0 && end < bounds.end) {
341
+ end = extendRun(end);
342
+ remaining--;
343
+ }
344
+ // A count that runs out of line before it is satisfied is a no-op in nvim,
345
+ // not a partial delete (`2diw` on the sole word `foo` preserves the
346
+ // register); cancel by returning null.
347
+ if (remaining > 0) return null;
348
+ return { startAbs: lineStartAbs + start, endAbs: lineStartAbs + end };
349
+ }
350
+
351
+ // kind === "a"
352
+ if (cursorClass === 0) {
353
+ // On whitespace: the run plus the following word(s); if there is no
354
+ // following word at all, there is nothing to select (nvim beeps / no-ops).
355
+ if (end >= bounds.end) return null;
356
+ let words = totalCount;
357
+ while (words > 0 && end < bounds.end) {
358
+ while (end < bounds.end && classAt(end) === 0) end++;
359
+ if (end >= bounds.end) break;
360
+ end = extendRun(end);
361
+ words--;
362
+ }
363
+ // Not enough following words to satisfy the count: no-op (nvim preserves
364
+ // the register) rather than deleting a partial span.
365
+ if (words > 0) return null;
366
+ return { startAbs: lineStartAbs + start, endAbs: lineStartAbs + end };
367
+ }
368
+
369
+ // On a word/punctuation run: `count` non-blank words with the whitespace
370
+ // between them, then trailing whitespace; if none, leading whitespace.
371
+ let words = totalCount - 1;
372
+ while (words > 0 && end < bounds.end) {
373
+ while (end < bounds.end && classAt(end) === 0) end++;
374
+ if (end >= bounds.end) break;
375
+ end = extendRun(end);
376
+ words--;
377
+ }
378
+ // The count reached past the last word: no-op (nvim preserves the register,
379
+ // e.g. `2daw` on the final word) instead of deleting whatever was collected.
380
+ if (words > 0) return null;
381
+
382
+ if (classAt(end) === 0) {
383
+ end = extendRun(end); // trailing whitespace
384
+ } else {
385
+ while (start > bounds.start && classAt(start - 1) === 0) start--;
386
+ }
387
+
388
+ return { startAbs: lineStartAbs + start, endAbs: lineStartAbs + end };
389
+ }