@xzzpig/pi-starline 0.1.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 (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. package/package.json +60 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Collapse pasted text at a lower line count than Pi's built-in threshold.
3
+ *
4
+ * Pi collapses a paste into a `[paste #N +L lines]` marker at more than ten
5
+ * lines (or a thousand characters), hardcoded. `pasteCollapseLines` takes over
6
+ * the range Pi leaves inline and collapses it the same way, so everything
7
+ * downstream — expansion on submit, deleting the marker, renumbering — keeps
8
+ * working because what we store is indistinguishable from what Pi stores.
9
+ *
10
+ * That last point is the whole risk. If our copy of Pi's cleaning drifts, the
11
+ * marker still looks right but the text behind it is wrong, and the damage only
12
+ * shows up on submit. So the cleaning below mirrors Pi's exactly, and anything
13
+ * Pi would have handled differently is handed straight back to it.
14
+ *
15
+ * Ported from pi-powerline-footer, where this has been in daily use.
16
+ *
17
+ * @internal
18
+ */
19
+
20
+ /** Pi's own bounds. Below the floor there is nothing to take over. */
21
+ const MIN_COLLAPSE_LINES = 2;
22
+ const MAX_COLLAPSE_LINES = 10;
23
+ /** Pi collapses above these itself. */
24
+ const PI_LINE_THRESHOLD = 10;
25
+ const PI_CHAR_THRESHOLD = 1000;
26
+
27
+ /** Label shown on the editor box border while a collapsed paste can be expanded. */
28
+ export const PASTE_EXPAND_HINT = "paste again to expand";
29
+
30
+ type EditorState = { lines?: unknown; cursorLine?: unknown; cursorCol?: unknown };
31
+
32
+ type EditorPasteInternals = {
33
+ handlePaste?: (text: string) => void;
34
+ handleInput?: (data: string) => unknown;
35
+ normalizeText?: (text: string) => string;
36
+ insertTextAtCursorInternal?: (text: string) => void;
37
+ cancelAutocomplete?: () => void;
38
+ exitHistoryBrowsing?: () => void;
39
+ pushUndoSnapshot?: () => void;
40
+ setCursorCol?: (col: number) => void;
41
+ pastes?: unknown;
42
+ pasteCounter?: unknown;
43
+ lastAction?: unknown;
44
+ state?: EditorState;
45
+ isInPaste?: unknown;
46
+ };
47
+
48
+ /** A collapsed paste that re-pasting the same text would expand in place. */
49
+ type ArmedPaste = { id: number; content: string };
50
+
51
+ function asPasteInternals(value: unknown): EditorPasteInternals | null {
52
+ if (typeof value !== "object" || value === null) return null;
53
+ const editor = value as EditorPasteInternals;
54
+ if (typeof editor.handlePaste !== "function") return null;
55
+ if (typeof editor.normalizeText !== "function") return null;
56
+ if (typeof editor.insertTextAtCursorInternal !== "function") return null;
57
+ if (!(editor.pastes instanceof Map)) return null;
58
+ return editor;
59
+ }
60
+
61
+ /** Whether this editor exposes enough to lower the collapse threshold. */
62
+ export function supportsPasteCollapse(value: unknown): boolean {
63
+ return asPasteInternals(value) !== null;
64
+ }
65
+
66
+ /**
67
+ * Reproduce Pi's pre-collapse cleaning: decode the CSI-u control re-encoding
68
+ * some terminals apply inside a bracketed paste, normalise line endings and
69
+ * tabs, then drop non-printables except newlines.
70
+ */
71
+ function cleanPastedText(editor: EditorPasteInternals, pastedText: string): string {
72
+ const decoded = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code: string) => {
73
+ const codePoint = Number(code);
74
+ if (codePoint >= 97 && codePoint <= 122) return String.fromCharCode(codePoint - 96);
75
+ if (codePoint >= 65 && codePoint <= 90) return String.fromCharCode(codePoint - 64);
76
+ return match;
77
+ });
78
+ const normalized = editor.normalizeText?.call(editor, decoded) ?? decoded;
79
+ return normalized
80
+ .split("")
81
+ .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
82
+ .join("");
83
+ }
84
+
85
+ /** Whether we should collapse a paste Pi would have left inline. */
86
+ export function shouldCollapse(text: string, minLines: number): boolean {
87
+ if (minLines < MIN_COLLAPSE_LINES || minLines > MAX_COLLAPSE_LINES) return false;
88
+ // A path Pi may reformat; leave those alone entirely.
89
+ if (/^[/~.]/.test(text)) return false;
90
+ const lineCount = text.split("\n").length;
91
+ if (lineCount > PI_LINE_THRESHOLD || text.length > PI_CHAR_THRESHOLD) return false;
92
+ return lineCount >= minLines;
93
+ }
94
+
95
+ /**
96
+ * Match one paste placeholder for `id`: `[paste #3 +42 lines]` or
97
+ * `[paste #3 1234 chars]`. Mirrors the marker Pi writes when it collapses.
98
+ */
99
+ function pasteMarkerRegex(id: number): RegExp {
100
+ return new RegExp(`\\[paste #${id}( (\\+\\d+ lines|\\d+ chars))?\\]`);
101
+ }
102
+
103
+ function editorLines(editor: EditorPasteInternals): string[] | null {
104
+ const lines = editor.state?.lines;
105
+ return Array.isArray(lines) ? (lines as string[]) : null;
106
+ }
107
+
108
+ /**
109
+ * Replace `armed`'s marker with the text behind it, in place.
110
+ *
111
+ * Refuses unless the stored text still is what was armed: Pi renumbers paste ids
112
+ * when a marker is deleted (`editor.ts` handleBackspace), so the armed id can
113
+ * come to point at somebody else's paste. Expanding then would swap in the wrong
114
+ * content silently, which is worse than not expanding at all.
115
+ */
116
+ function expandPasteInPlace(editor: EditorPasteInternals, armed: ArmedPaste): boolean {
117
+ const pastes = editor.pastes;
118
+ if (!(pastes instanceof Map)) return false;
119
+ if (pastes.get(armed.id) !== armed.content) return false;
120
+
121
+ const lines = editorLines(editor);
122
+ if (!lines) return false;
123
+
124
+ const text = lines.join("\n");
125
+ const match = pasteMarkerRegex(armed.id).exec(text);
126
+ if (!match) return false;
127
+
128
+ editor.pushUndoSnapshot?.call(editor);
129
+
130
+ const result =
131
+ text.slice(0, match.index) + armed.content + text.slice(match.index + match[0].length);
132
+ const resultLines = result.split("\n");
133
+ if (!editor.state) return false;
134
+ editor.state.lines = resultLines.length === 0 ? [""] : resultLines;
135
+
136
+ // Cursor lands at the end of the text that just appeared.
137
+ const before = result.slice(0, match.index + armed.content.length);
138
+ const cursorLine = before.match(/\n/g)?.length ?? 0;
139
+ const cursorCol = before.length - (before.lastIndexOf("\n") + 1);
140
+ editor.state.cursorLine = Math.min(cursorLine, Math.max(0, resultLines.length - 1));
141
+ if (typeof editor.setCursorCol === "function") {
142
+ editor.setCursorCol.call(editor, cursorCol);
143
+ } else {
144
+ editor.state.cursorCol = cursorCol;
145
+ }
146
+
147
+ // The marker is gone from the text, so its entry can only mislead. Leave
148
+ // pasteCounter alone: decrementing it would collide with markers still there.
149
+ pastes.delete(armed.id);
150
+ return true;
151
+ }
152
+
153
+ /** Hint reader for the currently installed editor, if any. */
154
+ let activeHint: (() => string | null) | null = null;
155
+
156
+ /**
157
+ * The "paste again to expand" label while a collapsed paste is armed, else null.
158
+ * Read by the selection controller, which puts it on the editor box border.
159
+ */
160
+ export function pasteExpandHintText(): string | null {
161
+ return activeHint?.() ?? null;
162
+ }
163
+
164
+ /**
165
+ * Shadow the editor's paste handler so pastes collapse earlier.
166
+ *
167
+ * Returns a disposer, or undefined when the editor does not expose what this
168
+ * needs — in which case nothing is patched and Pi's own threshold stands.
169
+ */
170
+ export function installPasteCollapse(
171
+ value: unknown,
172
+ getMinLines: () => number,
173
+ ): (() => void) | undefined {
174
+ const editor = asPasteInternals(value);
175
+ if (!editor) return undefined;
176
+
177
+ const base = editor.handlePaste;
178
+ if (typeof base !== "function") return undefined;
179
+
180
+ // Pi keeps handlePaste on the prototype, where deleting our shadow reveals it
181
+ // again. Somebody else may keep it on the instance, where deleting would take
182
+ // the real handler with it — so restore whatever was actually there.
183
+ const previousOwn = Object.getOwnPropertyDescriptor(editor, "handlePaste");
184
+
185
+ /** The collapsed paste that an identical re-paste would expand. */
186
+ let armed: ArmedPaste | null = null;
187
+ /** Set by the shadow so the input wrapper can tell a paste from a keystroke. */
188
+ let sawPaste = false;
189
+
190
+ const counterOf = (): number =>
191
+ typeof editor.pasteCounter === "number" ? editor.pasteCounter : 0;
192
+
193
+ /**
194
+ * Hand the paste to Pi, then arm the hint if Pi collapsed it itself (above its
195
+ * own hardcoded threshold, which is where most real pastes land).
196
+ */
197
+ const delegate = (pastedText: string, filtered: string | null): void => {
198
+ const before = counterOf();
199
+ base.call(editor, pastedText);
200
+ const after = counterOf();
201
+ const pastes = editor.pastes;
202
+ const stored = after > before && pastes instanceof Map ? pastes.get(after) : undefined;
203
+ armed = typeof stored === "string" ? { id: after, content: stored } : null;
204
+ // Nothing to compare a re-paste against if the cleaning drifted.
205
+ if (armed && filtered !== null && filtered !== armed.content) armed = null;
206
+ };
207
+
208
+ const shadow = function (this: unknown, pastedText: string): void {
209
+ sawPaste = true;
210
+ try {
211
+ const minLines = getMinLines();
212
+ const pastes = editor.pastes;
213
+ if (!(pastes instanceof Map)) {
214
+ base.call(editor, pastedText);
215
+ return;
216
+ }
217
+
218
+ const filtered = cleanPastedText(editor, pastedText);
219
+
220
+ // The same text pasted twice in a row expands the marker instead of
221
+ // stacking a second one.
222
+ if (armed && armed.content === filtered && expandPasteInPlace(editor, armed)) {
223
+ armed = null;
224
+ return;
225
+ }
226
+
227
+ if (!shouldCollapse(filtered, minLines)) {
228
+ delegate(pastedText, filtered);
229
+ return;
230
+ }
231
+
232
+ // Mirror what Pi does around its own collapse, so undo and
233
+ // autocomplete behave the same either way.
234
+ editor.cancelAutocomplete?.call(editor);
235
+ editor.exitHistoryBrowsing?.call(editor);
236
+ editor.lastAction = null;
237
+ editor.pushUndoSnapshot?.call(editor);
238
+
239
+ const counter = typeof editor.pasteCounter === "number" ? editor.pasteCounter : 0;
240
+ const id = counter + 1;
241
+ editor.pasteCounter = id;
242
+ pastes.set(id, filtered);
243
+ const lineCount = filtered.split("\n").length;
244
+ editor.insertTextAtCursorInternal?.call(editor, `[paste #${id} +${lineCount} lines]`);
245
+ armed = { id, content: filtered };
246
+ } catch {
247
+ // Never let this swallow a paste: fall back to Pi's own handling.
248
+ armed = null;
249
+ base.call(editor, pastedText);
250
+ }
251
+ };
252
+
253
+ // An own property shadows the prototype method for this instance only.
254
+ Object.defineProperty(editor, "handlePaste", {
255
+ configurable: true,
256
+ enumerable: false,
257
+ writable: true,
258
+ value: shadow,
259
+ });
260
+
261
+ // Anything that is not a paste puts the offer away. Pi feeds a bracketed paste
262
+ // in over several calls with isInPaste set, so only the tail of one counts.
263
+ const baseInput = editor.handleInput;
264
+ const previousOwnInput = Object.getOwnPropertyDescriptor(editor, "handleInput");
265
+ const inputShadow =
266
+ typeof baseInput === "function"
267
+ ? function (this: unknown, data: string): unknown {
268
+ sawPaste = false;
269
+ const result = baseInput.call(editor, data);
270
+ if (!sawPaste && editor.isInPaste !== true) armed = null;
271
+ return result;
272
+ }
273
+ : null;
274
+ if (inputShadow) {
275
+ Object.defineProperty(editor, "handleInput", {
276
+ configurable: true,
277
+ enumerable: false,
278
+ writable: true,
279
+ value: inputShadow,
280
+ });
281
+ }
282
+
283
+ activeHint = () => {
284
+ if (!armed) return null;
285
+ // Self-clearing: the marker may have been deleted since it was armed.
286
+ const lines = editorLines(editor);
287
+ if (!lines || !pasteMarkerRegex(armed.id).test(lines.join("\n"))) {
288
+ armed = null;
289
+ return null;
290
+ }
291
+ return PASTE_EXPAND_HINT;
292
+ };
293
+ const ownHint = activeHint;
294
+
295
+ return () => {
296
+ armed = null;
297
+ if (activeHint === ownHint) activeHint = null;
298
+ if (inputShadow && (editor as { handleInput?: unknown }).handleInput === inputShadow) {
299
+ if (previousOwnInput) {
300
+ Object.defineProperty(editor, "handleInput", previousOwnInput);
301
+ } else {
302
+ delete (editor as { handleInput?: unknown }).handleInput;
303
+ }
304
+ }
305
+ if ((editor as { handlePaste?: unknown }).handlePaste !== shadow) return;
306
+ if (previousOwn) {
307
+ Object.defineProperty(editor, "handlePaste", previousOwn);
308
+ return;
309
+ }
310
+ delete (editor as { handlePaste?: unknown }).handlePaste;
311
+ };
312
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Configuration for the pill footer style.
3
+ *
4
+ * The pill bar is a second rendering of the footer, not a second footer: it
5
+ * draws the same segments as coloured blocks joined by seamless arrows instead
6
+ * of as coloured text. `footerStyle: "text"` keeps `footerFormat` and upstream
7
+ * behaviour exactly; `footerStyle: "pill"` ignores `footerFormat` and lays the
8
+ * bar out from `pill.segments`.
9
+ */
10
+
11
+ export type FooterStyle = "text" | "pill";
12
+
13
+ /** Glyph drawn between two pills. */
14
+ export type PillSeparator = "powerline" | "powerline-thin" | "none";
15
+
16
+ /** How the two ends of the bar are closed off. */
17
+ export type PillCaps = "round" | "right" | "none";
18
+
19
+ export type PillConfig = {
20
+ /**
21
+ * Ordered segments, left to right. Names are `footerSegments` keys, plus
22
+ * `extensionStatus` for every status not already listed and
23
+ * `extensionStatus:<key>` to place one specific status.
24
+ */
25
+ segments: string[];
26
+ separator: PillSeparator;
27
+ bold: boolean;
28
+ caps: PillCaps;
29
+ };
30
+
31
+ export const EXTENSION_STATUS_SEGMENT = "extensionStatus";
32
+ export const EXTENSION_STATUS_PREFIX = `${EXTENSION_STATUS_SEGMENT}:`;
33
+
34
+ /** Segment names that map onto a footer variable. */
35
+ export const PILL_SEGMENT_VARIABLES: Record<string, string> = {
36
+ model: "model",
37
+ thinking: "thinking",
38
+ cwd: "cwd",
39
+ sessionName: "session_name",
40
+ gitBranch: "git_branch",
41
+ gitStatus: "git_status",
42
+ gitState: "git_state",
43
+ gitCommit: "git_commit",
44
+ gitTag: "git_tag",
45
+ gitMetrics: "git_metrics",
46
+ runtime: "runtime",
47
+ context: "context",
48
+ tokens: "tokens",
49
+ cacheHit: "cache_hit",
50
+ cost: "cost",
51
+ sessionDuration: "session_duration",
52
+ username: "username",
53
+ time: "time",
54
+ os: "os",
55
+ packageVersion: "package_version",
56
+ };
57
+
58
+ export const DEFAULT_PILL_CONFIG: PillConfig = {
59
+ segments: [
60
+ "model",
61
+ "thinking",
62
+ "cwd",
63
+ "gitBranch",
64
+ "gitStatus",
65
+ "context",
66
+ "cost",
67
+ EXTENSION_STATUS_SEGMENT,
68
+ ],
69
+ separator: "powerline",
70
+ bold: true,
71
+ caps: "round",
72
+ };
73
+
74
+ function isRecord(value: unknown): value is Record<string, unknown> {
75
+ return typeof value === "object" && value !== null && !Array.isArray(value);
76
+ }
77
+
78
+ export function parseFooterStyle(value: unknown): FooterStyle {
79
+ return value === "pill" ? "pill" : "text";
80
+ }
81
+
82
+ function parseSeparator(value: unknown): PillSeparator {
83
+ return value === "powerline-thin" || value === "none" || value === "powerline"
84
+ ? value
85
+ : DEFAULT_PILL_CONFIG.separator;
86
+ }
87
+
88
+ function parseCaps(value: unknown): PillCaps {
89
+ return value === "right" || value === "none" || value === "round"
90
+ ? value
91
+ : DEFAULT_PILL_CONFIG.caps;
92
+ }
93
+
94
+ /** A segment name is usable if it maps to a variable or addresses a status. */
95
+ export function isKnownPillSegment(name: string): boolean {
96
+ if (name === EXTENSION_STATUS_SEGMENT) return true;
97
+ if (name.startsWith(EXTENSION_STATUS_PREFIX)) return name.length > EXTENSION_STATUS_PREFIX.length;
98
+ return name in PILL_SEGMENT_VARIABLES;
99
+ }
100
+
101
+ function parseSegments(value: unknown): string[] {
102
+ if (!Array.isArray(value)) return DEFAULT_PILL_CONFIG.segments;
103
+ const segments: string[] = [];
104
+ for (const entry of value) {
105
+ if (typeof entry !== "string") continue;
106
+ const name = entry.trim();
107
+ // Unknown names are dropped rather than rendered as empty pills, so a
108
+ // typo shortens the bar instead of punching a hole in it.
109
+ if (name && isKnownPillSegment(name) && !segments.includes(name)) segments.push(name);
110
+ }
111
+ return segments;
112
+ }
113
+
114
+ export function normalizePillConfig(value: unknown): PillConfig {
115
+ if (!isRecord(value)) return { ...DEFAULT_PILL_CONFIG };
116
+ return {
117
+ segments: parseSegments(value.segments),
118
+ separator: parseSeparator(value.separator),
119
+ bold: typeof value.bold === "boolean" ? value.bold : DEFAULT_PILL_CONFIG.bold,
120
+ caps: parseCaps(value.caps),
121
+ };
122
+ }