@xzzpig/pi-starline 0.5.1 → 0.7.1
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/FORK-CHANGELOG.md +19 -0
- package/README.md +68 -181
- package/extensions/starline/config.ts +156 -326
- package/extensions/starline/context-live.ts +33 -0
- package/extensions/starline/editor-cursor.ts +3 -0
- package/extensions/starline/extension-status.ts +9 -5
- package/extensions/starline/footer.ts +331 -528
- package/extensions/starline/fork-editor-cursor.ts +14 -0
- package/extensions/starline/format.ts +151 -20
- package/extensions/starline/git-live.ts +20 -0
- package/extensions/starline/git-probe.ts +40 -0
- package/extensions/starline/git-segments.ts +175 -0
- package/extensions/starline/git.ts +71 -0
- package/extensions/starline/icons.ts +17 -0
- package/extensions/starline/index.ts +188 -91
- package/extensions/starline/live-session.ts +164 -0
- package/extensions/starline/live-tps.ts +165 -0
- package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
- package/extensions/starline/mouse/cursor-marker.ts +69 -0
- package/extensions/starline/mouse/editor-hit-test.ts +6 -3
- package/extensions/starline/mouse/external-layer.ts +42 -0
- package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
- package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
- package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
- package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
- package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
- package/extensions/starline/mouse/hint.ts +4 -4
- package/extensions/starline/mouse/index.ts +54 -24
- package/extensions/starline/mouse/pointer-events.ts +73 -0
- package/extensions/starline/mouse/pointer-shape.ts +138 -0
- package/extensions/starline/mouse/transcript-copy.ts +76 -33
- package/extensions/starline/paste-collapse.ts +52 -13
- package/extensions/starline/pill-actions.ts +590 -0
- package/extensions/starline/pill-anchors.ts +26 -0
- package/extensions/starline/pill-api.ts +80 -0
- package/extensions/starline/pill-bubble.ts +607 -0
- package/extensions/starline/pill-config.ts +67 -9
- package/extensions/starline/pill.ts +102 -20
- package/extensions/starline/project-probe.ts +79 -0
- package/extensions/starline/prototype-patch-registry.ts +6 -1
- package/extensions/starline/queue-rows.ts +43 -0
- package/extensions/starline/settings-command.ts +124 -177
- package/extensions/starline/subagent-fleet.ts +246 -0
- package/extensions/starline/turn-clock.ts +81 -0
- package/extensions/starline/ui.ts +297 -156
- package/extensions/starline/working-indicator.ts +189 -0
- package/package.json +6 -5
- package/types/pill-api.d.ts +115 -0
- package/extensions/starline/editor-metadata-format.ts +0 -259
- package/extensions/starline/footer-format.ts +0 -279
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode throughput of the latest assistant message, for the `tps` pill.
|
|
3
|
+
*
|
|
4
|
+
* The denominator is the part that is easy to get wrong, so the window is
|
|
5
|
+
* explicit: it opens on the first streamed delta and closes when the message is
|
|
6
|
+
* done. Queueing, time-to-first-token and every tool call in the turn fall
|
|
7
|
+
* outside it, which is what "how fast is this model writing" should mean.
|
|
8
|
+
* `usage.output` is the numerator: it is the one token count the provider reports
|
|
9
|
+
* for the message itself, and it includes thinking tokens.
|
|
10
|
+
*
|
|
11
|
+
* Two halves on purpose. `observe` only sees streaming events, because Pi turns
|
|
12
|
+
* just `text_delta` / `thinking_*` / `toolcall_*` into `message_update` — the
|
|
13
|
+
* stream's `done` event never reaches an extension. The output count therefore
|
|
14
|
+
* has to come from `message_end`, which is what `complete` takes.
|
|
15
|
+
*
|
|
16
|
+
* A provider that hands back a whole message in a single chunk has no stream to
|
|
17
|
+
* measure. Those messages are skipped rather than turned into a fabricated
|
|
18
|
+
* number, and the pill keeps the previous value instead of blinking to blank.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Below this the window is noise: two deltas 20 ms apart divide output by
|
|
25
|
+
* almost nothing and report a throughput no model produced. Real streams clear
|
|
26
|
+
* it by an order of magnitude.
|
|
27
|
+
*/
|
|
28
|
+
const MIN_WINDOW_MS = 100;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One delta is a non-streaming reply, not a measurement.
|
|
32
|
+
*/
|
|
33
|
+
const MIN_DELTAS = 2;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The live value needs a longer window than the final one: a single early delta
|
|
37
|
+
* divided by 30 ms reads as thousands of tokens per second, and the pill would
|
|
38
|
+
* flash a nonsense number before settling.
|
|
39
|
+
*/
|
|
40
|
+
const MIN_LIVE_WINDOW_MS = 400;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How often the live value may be replaced. The footer already re-renders on the
|
|
44
|
+
* spinner's tick, so this is not about cost — it is about the number being
|
|
45
|
+
* readable instead of a blur.
|
|
46
|
+
*/
|
|
47
|
+
const LIVE_UPDATE_MS = 500;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Rough token count for streamed text.
|
|
51
|
+
*
|
|
52
|
+
* Pi only reports usage in the final streamed chunk, so a live number cannot use
|
|
53
|
+
* the real count. CJK glyphs cost about a token each and Latin text about four
|
|
54
|
+
* characters per token, which lands within a few tens of percent of the real
|
|
55
|
+
* tokenizer — enough for a moving number that gets corrected at message end.
|
|
56
|
+
*/
|
|
57
|
+
export function estimateTokens(text: string): number {
|
|
58
|
+
let wide = 0;
|
|
59
|
+
let total = 0;
|
|
60
|
+
for (const character of text) {
|
|
61
|
+
total += 1;
|
|
62
|
+
const code = character.codePointAt(0) ?? 0;
|
|
63
|
+
const isWide =
|
|
64
|
+
(code >= 0x2e80 && code <= 0x9fff) || // CJK
|
|
65
|
+
(code >= 0x3040 && code <= 0x30ff) || // kana
|
|
66
|
+
(code >= 0xac00 && code <= 0xd7af); // hangul
|
|
67
|
+
if (isWide) wide += 1;
|
|
68
|
+
}
|
|
69
|
+
return wide + (total - wide) / 4;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class LiveTpsController {
|
|
73
|
+
private firstDeltaAt: number | undefined;
|
|
74
|
+
private deltas = 0;
|
|
75
|
+
/** Estimated tokens seen so far in the streaming message, for the live value. */
|
|
76
|
+
private streamedTokens = 0;
|
|
77
|
+
private lastLiveAt = 0;
|
|
78
|
+
private latest: number | undefined;
|
|
79
|
+
|
|
80
|
+
/** Most recent measured throughput, or undefined before the first one. */
|
|
81
|
+
value(): number | undefined {
|
|
82
|
+
return this.latest;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* End a turn's measurement window without discarding the value. Called when a
|
|
87
|
+
* new agent run starts: the pill keeps showing the last turn's speed until the
|
|
88
|
+
* next message finishes.
|
|
89
|
+
*/
|
|
90
|
+
beginTurn(): void {
|
|
91
|
+
this.firstDeltaAt = undefined;
|
|
92
|
+
this.deltas = 0;
|
|
93
|
+
this.streamedTokens = 0;
|
|
94
|
+
this.lastLiveAt = 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True while a message is streaming, which is when the value is an estimate. */
|
|
98
|
+
streaming(): boolean {
|
|
99
|
+
return this.firstDeltaAt !== undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Forget everything, value included. A session is over or changed. */
|
|
103
|
+
clear(): void {
|
|
104
|
+
this.beginTurn();
|
|
105
|
+
this.latest = undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Feed one streaming event. Returns true when the visible number changed, so
|
|
110
|
+
* the caller can re-render the footer.
|
|
111
|
+
*
|
|
112
|
+
* The live value is an estimate over the whole window so far, not a sliding
|
|
113
|
+
* one: a model that stalls for 800 ms would otherwise show a rate that drops
|
|
114
|
+
* to nothing even though its average is fine.
|
|
115
|
+
*
|
|
116
|
+
* `event` is optional because older Pi builds (and some test harnesses) fire
|
|
117
|
+
* `message_update` without the `assistantMessageEvent` field.
|
|
118
|
+
*/
|
|
119
|
+
observe(event: AssistantMessageEvent | undefined): boolean {
|
|
120
|
+
if (!event) return false;
|
|
121
|
+
switch (event.type) {
|
|
122
|
+
case "start":
|
|
123
|
+
this.beginTurn();
|
|
124
|
+
return false;
|
|
125
|
+
case "text_delta":
|
|
126
|
+
case "thinking_delta":
|
|
127
|
+
case "toolcall_delta": {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
// `firstDeltaAt` is a timestamp, so 0 is a legal value: test not-assigned
|
|
130
|
+
// rather than falsy.
|
|
131
|
+
if (this.firstDeltaAt === undefined) {
|
|
132
|
+
this.firstDeltaAt = now;
|
|
133
|
+
this.lastLiveAt = 0;
|
|
134
|
+
}
|
|
135
|
+
this.deltas += 1;
|
|
136
|
+
this.streamedTokens += estimateTokens(event.delta);
|
|
137
|
+
const windowMs = now - this.firstDeltaAt;
|
|
138
|
+
if (windowMs < MIN_LIVE_WINDOW_MS) return false;
|
|
139
|
+
if (now - this.lastLiveAt < LIVE_UPDATE_MS) return false;
|
|
140
|
+
this.lastLiveAt = now;
|
|
141
|
+
this.latest = this.streamedTokens / (windowMs / 1000);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
default:
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Close the window with the message's own output count. Returns true when the
|
|
151
|
+
* measurement landed, so the caller can re-render the footer.
|
|
152
|
+
*/
|
|
153
|
+
complete(output: number): boolean {
|
|
154
|
+
const first = this.firstDeltaAt;
|
|
155
|
+
const deltas = this.deltas;
|
|
156
|
+
const windowMs = first === undefined ? 0 : Date.now() - first;
|
|
157
|
+
this.beginTurn();
|
|
158
|
+
if (first === undefined) return false;
|
|
159
|
+
if (deltas < MIN_DELTAS) return false;
|
|
160
|
+
if (!Number.isFinite(output) || output <= 0) return false;
|
|
161
|
+
if (windowMs < MIN_WINDOW_MS) return false;
|
|
162
|
+
this.latest = output / (windowMs / 1000);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A click anywhere outside an open pill bubble puts it away.
|
|
3
|
+
*
|
|
4
|
+
* A bubble is a popover, not a panel: it covers the transcript while it is open
|
|
5
|
+
* and the way out should be wherever the user's pointer already is. Escape, a
|
|
6
|
+
* second click on the pill, and another pill all already close it; the rest of
|
|
7
|
+
* the screen — the editor, the transcript, an empty row — did nothing, so a
|
|
8
|
+
* bubble stayed up until one of those three happened.
|
|
9
|
+
*
|
|
10
|
+
* The click has to be read before Pi dispatches it, because the thing under the
|
|
11
|
+
* pointer is a component that knows nothing about bubbles. There is exactly one
|
|
12
|
+
* patch on the renderer's mouse entry (`pointer-shape.ts`, adapter
|
|
13
|
+
* `mouse-pointer-shape`) and the registry holds one behaviour per method, so this
|
|
14
|
+
* subscribes to the events that patch publishes instead of taking a second one.
|
|
15
|
+
* Nothing here consumes the event: the click goes on to move the caret, select a
|
|
16
|
+
* line, or whatever else it was going to do.
|
|
17
|
+
*
|
|
18
|
+
* The footer row is handled by the footer itself, which knows which cells are
|
|
19
|
+
* pills: a press there either belongs to a clickable pill (its own toggle or
|
|
20
|
+
* replacement) or lands on padding, a gap or an inert read-out, and that last
|
|
21
|
+
* case closes the bubble too. So the geometry check below stays off that row —
|
|
22
|
+
* closing on the press here as well would make the same pill's second click
|
|
23
|
+
* close and immediately reopen.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { activePillBubbleGeometry, closePillBubble, type PillBubbleGeometry } from "../pill-bubble";
|
|
27
|
+
import {
|
|
28
|
+
isPointerPress,
|
|
29
|
+
observePointerEvents,
|
|
30
|
+
type PointerEventRaw,
|
|
31
|
+
type PointerReceiver,
|
|
32
|
+
} from "./pointer-events";
|
|
33
|
+
|
|
34
|
+
/** Rows between the bottom of the screen and the footer: none. */
|
|
35
|
+
const FOOTER_LAST_ROW = 1;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether this pointer event lands outside the bubble's rectangle.
|
|
39
|
+
*
|
|
40
|
+
* The rectangle is resolved against the terminal as it is now, not as it was when
|
|
41
|
+
* the bubble opened, so a window that grew keeps the bubble on its footer. A
|
|
42
|
+
* press on the footer row never counts here: the footer decides that row cell by
|
|
43
|
+
* cell, because it is the one place that knows where its pills are.
|
|
44
|
+
*/
|
|
45
|
+
export function isOutsideBubble(
|
|
46
|
+
event: PointerEventRaw,
|
|
47
|
+
geometry: PillBubbleGeometry | undefined,
|
|
48
|
+
terminalRows: number | undefined,
|
|
49
|
+
): boolean {
|
|
50
|
+
if (!geometry || geometry.width <= 0 || geometry.height <= 0) return false;
|
|
51
|
+
if (terminalRows === undefined || !Number.isFinite(terminalRows)) return false;
|
|
52
|
+
if (!isPointerPress(event)) return false;
|
|
53
|
+
|
|
54
|
+
// The overlay is anchored to the bottom with a one-row gap for the footer, so
|
|
55
|
+
// its last row is that many rows above the screen's last one.
|
|
56
|
+
const bottomRow = terminalRows - FOOTER_LAST_ROW - geometry.bottomOffset;
|
|
57
|
+
const topRow = bottomRow - geometry.height + 1;
|
|
58
|
+
const left = geometry.leftColumn;
|
|
59
|
+
const right = left + geometry.width;
|
|
60
|
+
|
|
61
|
+
// The bar itself: its pills toggle and replace their own bubbles, and the rest
|
|
62
|
+
// of the row is the footer's to dismiss from (see `handleMouse` there).
|
|
63
|
+
if (event.y === terminalRows - FOOTER_LAST_ROW) return false;
|
|
64
|
+
|
|
65
|
+
return event.y < topRow || event.y > bottomRow || event.x < left || event.x >= right;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Close the open bubble when a press lands outside it.
|
|
70
|
+
*
|
|
71
|
+
* Returns the disposer. Harmless on a Pi without component mouse events: no
|
|
72
|
+
* events are ever published, so nothing happens and the bubble keeps its other
|
|
73
|
+
* three ways out.
|
|
74
|
+
*/
|
|
75
|
+
export function installPillBubbleDismiss(): () => void {
|
|
76
|
+
return observePointerEvents((event, receiver: PointerReceiver | undefined) => {
|
|
77
|
+
const geometry = activePillBubbleGeometry();
|
|
78
|
+
if (!geometry) return;
|
|
79
|
+
if (!isOutsideBubble(event, geometry, receiver?.terminal?.rows)) return;
|
|
80
|
+
closePillBubble();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stopgap for Pi's cursor-marker leak during mouse selection.
|
|
3
|
+
*
|
|
4
|
+
* `TuiAltScreen.applySelection` cuts a selected row into `before` / `selected` /
|
|
5
|
+
* `after` with `sliceByColumn`, and `sliceWithWidth` carries every escape sequence
|
|
6
|
+
* that precedes a slice into it (`pendingAnsi`). The editor's caret marker
|
|
7
|
+
* (`\x1b_pi:c\x07`, a zero-width APC) therefore gets copied into each later slice:
|
|
8
|
+
* one marker becomes two or three.
|
|
9
|
+
*
|
|
10
|
+
* `extractCursorPosition` strips only the first copy per line and returns, so the
|
|
11
|
+
* rest are written to the terminal. Ghostty only ends an APC on `ESC \`, treats the
|
|
12
|
+
* `BEL` as payload, and swallows the bytes that follow — which shows up as
|
|
13
|
+
* garbled text in and around the editor while dragging. Upstream issue
|
|
14
|
+
* earendil-works/pi#9332; the fix is earendil-works/pi#9441, still unmerged as of
|
|
15
|
+
* Pi 1.4.0.
|
|
16
|
+
*
|
|
17
|
+
* Starline makes this fire on every editor drag-select because
|
|
18
|
+
* `editorClickToCaret` moves the caret to the press point, so the caret row is
|
|
19
|
+
* always the row being dragged over. This module keeps the first marker per line
|
|
20
|
+
* and drops the duplicates — the same thing #9441 does in `extractCursorPosition`,
|
|
21
|
+
* from the outside. **Delete it once a Pi release contains #9441.**
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { CURSOR_MARKER } from "@earendil-works/pi-tui";
|
|
25
|
+
import { installPrototypePatch } from "../prototype-patch-registry";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keep the first cursor marker on a line, drop the rest.
|
|
29
|
+
*
|
|
30
|
+
* The first one is the one `extractCursorPosition` reads the caret column from, so
|
|
31
|
+
* keeping it preserves the caret exactly; the extras are what leak.
|
|
32
|
+
*/
|
|
33
|
+
export function dedupeCursorMarkers(lines: readonly string[]): string[] {
|
|
34
|
+
return lines.map((line) => {
|
|
35
|
+
const first = line.indexOf(CURSOR_MARKER);
|
|
36
|
+
if (first === -1) return line;
|
|
37
|
+
const head = line.slice(0, first + CURSOR_MARKER.length);
|
|
38
|
+
const rest = line.slice(first + CURSOR_MARKER.length);
|
|
39
|
+
return rest.includes(CURSOR_MARKER) ? head + rest.split(CURSOR_MARKER).join("") : line;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Installs the marker dedupe on `applySelection`, or does nothing on a build that
|
|
45
|
+
* has no selection rendering at all. Returns the disposer, or undefined when there
|
|
46
|
+
* was nothing to patch.
|
|
47
|
+
*/
|
|
48
|
+
export function installCursorMarkerDedupe(prototype: object): (() => void) | undefined {
|
|
49
|
+
const target = prototype as Record<string, unknown>;
|
|
50
|
+
if (typeof target.applySelection !== "function") return undefined;
|
|
51
|
+
try {
|
|
52
|
+
return installPrototypePatch(
|
|
53
|
+
prototype,
|
|
54
|
+
"applySelection",
|
|
55
|
+
"selection-marker-dedupe",
|
|
56
|
+
({ predecessor, receiver, args }) => {
|
|
57
|
+
const result = Reflect.apply(predecessor, receiver, args);
|
|
58
|
+
// Anything that is not the row array Pi documents is passed through
|
|
59
|
+
// untouched: this patch exists to clean a frame, not to reshape one.
|
|
60
|
+
if (!Array.isArray(result)) return result;
|
|
61
|
+
return dedupeCursorMarkers(result as string[]);
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
} catch {
|
|
65
|
+
// An unpatchable method means the leak stays, which is exactly how Pi
|
|
66
|
+
// behaves without Starline. Never fail an install over it.
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -45,7 +45,10 @@ export type EditorBoxGeometry = {
|
|
|
45
45
|
* they cannot be mistaken for a rule.
|
|
46
46
|
*
|
|
47
47
|
* Layout between the rules, per renderPolishedFrame:
|
|
48
|
-
* rule, [pad], ...text, [
|
|
48
|
+
* rule, [pad], ...text, [filler], [pad], rule
|
|
49
|
+
*
|
|
50
|
+
* Filler rows (`editorMinRows`) are counted as text rows here; a click on one
|
|
51
|
+
* lands past the end of the draft either way.
|
|
49
52
|
*/
|
|
50
53
|
export function findEditorBox(
|
|
51
54
|
clusterLines: string[],
|
|
@@ -72,8 +75,8 @@ export function findEditorBox(
|
|
|
72
75
|
|
|
73
76
|
const pad = paddingY > 0 ? 1 : 0;
|
|
74
77
|
const firstTextRow = top + 1 + pad;
|
|
75
|
-
// Below the text: the padding row, then the
|
|
76
|
-
const lastTextRow = bottom -
|
|
78
|
+
// Below the text: the padding row, then the rule.
|
|
79
|
+
const lastTextRow = bottom - 1 - pad;
|
|
77
80
|
if (lastTextRow < firstTextRow) return null;
|
|
78
81
|
|
|
79
82
|
return { firstTextRow, lastTextRow, textColumn };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection for an extension that already owns Pi's mouse layer.
|
|
3
|
+
*
|
|
4
|
+
* `@xzzpig/pi-mouse-events` patches the same `TuiAltScreen` methods Starline does
|
|
5
|
+
* (wheel, press, copy) from one place and publishes an API object under a
|
|
6
|
+
* process-global symbol so consumers do not have to import it — the way Pi loads
|
|
7
|
+
* extensions makes a static import of a package that may not be installed a
|
|
8
|
+
* problem, and `Symbol.for` is process-global by spec.
|
|
9
|
+
*
|
|
10
|
+
* Two layers patching the same prototype would both answer every notch, click and
|
|
11
|
+
* copy: the wheel would move twice, the caret would move twice, the clipboard
|
|
12
|
+
* would be written twice. Neither is wrong on its own, so the only safe answer is
|
|
13
|
+
* for the later layer to stand down and say so.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The key `@xzzpig/pi-mouse-events` publishes its API under, version-pinned. */
|
|
17
|
+
const MOUSE_EVENTS_API_KEY = Symbol.for("pi-mouse-events.api.v2");
|
|
18
|
+
const MOUSE_EVENTS_API_VERSION = 2;
|
|
19
|
+
|
|
20
|
+
export type ExternalMouseLayer = { name: string; version: number };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The extension that owns the mouse layer in this process, if any.
|
|
24
|
+
*
|
|
25
|
+
* Read from the global the way that package's own consumers do, and checked for
|
|
26
|
+
* the contract version: the symbol key pins the name, not the shape, so an object
|
|
27
|
+
* from a different contract major is not something to hand events to.
|
|
28
|
+
*/
|
|
29
|
+
export function externalMouseLayer(
|
|
30
|
+
scope: Record<PropertyKey, unknown> = globalThis,
|
|
31
|
+
): ExternalMouseLayer | undefined {
|
|
32
|
+
try {
|
|
33
|
+
const candidate = scope[MOUSE_EVENTS_API_KEY];
|
|
34
|
+
if (typeof candidate !== "object" || candidate === null) return undefined;
|
|
35
|
+
const version = (candidate as { version?: unknown }).version;
|
|
36
|
+
return version === MOUSE_EVENTS_API_VERSION
|
|
37
|
+
? { name: "@xzzpig/pi-mouse-events", version: MOUSE_EVENTS_API_VERSION }
|
|
38
|
+
: undefined;
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AsyncCopyHandler, CopyHandlerContext } from "@xzzpig/pi-mouse-events/api";
|
|
2
|
+
|
|
3
|
+
type CopyReceiver = {
|
|
4
|
+
copySelection?: (text: string) => boolean | string | Promise<boolean | string>;
|
|
5
|
+
terminal?: { write?: (data: string) => void };
|
|
6
|
+
flash?: (message: string) => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type CopyCandidates = {
|
|
10
|
+
receiver: CopyReceiver;
|
|
11
|
+
showNotice: boolean;
|
|
12
|
+
/** The editor gets first refusal, followed by the cleaned transcript. */
|
|
13
|
+
texts: Array<() => string | undefined>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async function copyText(
|
|
17
|
+
receiver: CopyReceiver,
|
|
18
|
+
text: string,
|
|
19
|
+
showNotice: boolean,
|
|
20
|
+
): Promise<boolean> {
|
|
21
|
+
if (typeof receiver.copySelection === "function") {
|
|
22
|
+
try {
|
|
23
|
+
if ((await receiver.copySelection(text)) === true) {
|
|
24
|
+
if (showNotice) receiver.flash?.("Copied!");
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// A failed native clipboard falls back to OSC 52, as upstream does.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
if (typeof receiver.terminal?.write !== "function") return false;
|
|
33
|
+
receiver.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
34
|
+
if (showNotice) receiver.flash?.("Copied!");
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build an async shared-layer handler; unavailable on older v2 publishers. */
|
|
42
|
+
export function nativeCopyHandler(
|
|
43
|
+
candidatesFor: (context: CopyHandlerContext) => CopyCandidates | undefined,
|
|
44
|
+
): AsyncCopyHandler {
|
|
45
|
+
return async (context) => {
|
|
46
|
+
const candidates = candidatesFor(context);
|
|
47
|
+
if (!candidates) return undefined;
|
|
48
|
+
for (const read of candidates.texts) {
|
|
49
|
+
const text = read();
|
|
50
|
+
if (text === undefined) continue;
|
|
51
|
+
if (text.length === 0 || (await copyText(candidates.receiver, text, candidates.showNotice))) {
|
|
52
|
+
return { handled: true };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
|
|
2
|
+
import { installPillBubbleDismiss } from "./bubble-dismiss";
|
|
3
|
+
import { type PointerReceiver, publishPointerEvent } from "./pointer-events";
|
|
4
|
+
|
|
5
|
+
/** Feed the upstream bubble observer from the shared, non-consuming mouse slot. */
|
|
6
|
+
export function installSharedPillMouse(api: MouseEventsApi): () => void {
|
|
7
|
+
const stopDismiss = installPillBubbleDismiss();
|
|
8
|
+
const stopEvents = api.addMouseHandler(
|
|
9
|
+
({ event, tui }) => {
|
|
10
|
+
// SAFETY: the shared API supplies the live TuiAltScreen; the observer only reads terminal.rows.
|
|
11
|
+
publishPointerEvent(event, tui as unknown as PointerReceiver);
|
|
12
|
+
},
|
|
13
|
+
{ priority: Number.MAX_SAFE_INTEGER },
|
|
14
|
+
);
|
|
15
|
+
return () => {
|
|
16
|
+
stopEvents();
|
|
17
|
+
stopDismiss();
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PointerShape } from "@xzzpig/pi-mouse-events/api";
|
|
2
|
+
import { getMouseEventsApi } from "./api-consumer";
|
|
3
|
+
|
|
4
|
+
/** Use the shared pointer owner when the installed v2 API supports it. */
|
|
5
|
+
export function requestSharedPointer(shape: PointerShape): boolean {
|
|
6
|
+
const api = getMouseEventsApi();
|
|
7
|
+
if (!api?.requestPointerShape) return false;
|
|
8
|
+
api.requestPointerShape(shape);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { type CleanedRows, cleanTranscriptRows } from "./transcript-copy";
|
|
3
|
+
|
|
4
|
+
/** Preserve Starline's configurable message rail alongside upstream's pi-tidy cleaning. */
|
|
5
|
+
export function cleanForkTranscriptRows(
|
|
6
|
+
lines: readonly string[],
|
|
7
|
+
startRow: number,
|
|
8
|
+
endRow: number,
|
|
9
|
+
railGlyph: string,
|
|
10
|
+
starlineOwnsMessages = true,
|
|
11
|
+
): CleanedRows {
|
|
12
|
+
const upstream = cleanTranscriptRows(lines, startRow, endRow);
|
|
13
|
+
// When tidy owns messages, its icon is decoration rather than user text.
|
|
14
|
+
if (railGlyph === "│" && !starlineOwnsMessages) return upstream;
|
|
15
|
+
|
|
16
|
+
const prefix = railGlyph ? `${railGlyph} ` : "";
|
|
17
|
+
const dropped = new Set<number>();
|
|
18
|
+
const railContent = new Set<number>();
|
|
19
|
+
let lastRule: { row: number; width: number } | undefined;
|
|
20
|
+
for (let row = 0; row < lines.length; row++) {
|
|
21
|
+
const text = stripTerminalSequences(lines[row] ?? "");
|
|
22
|
+
if (/^─+$/.test(text)) {
|
|
23
|
+
const width = visibleWidth(text);
|
|
24
|
+
if (lastRule && lastRule.width === width && row - lastRule.row >= 2) {
|
|
25
|
+
dropped.add(lastRule.row);
|
|
26
|
+
dropped.add(row);
|
|
27
|
+
for (let inner = lastRule.row + 1; inner < row; inner++) railContent.add(inner);
|
|
28
|
+
}
|
|
29
|
+
lastRule = { row, width };
|
|
30
|
+
} else if (prefix && !text.startsWith(prefix)) {
|
|
31
|
+
lastRule = undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let changed = upstream.changed;
|
|
36
|
+
const rows = upstream.rows.map((cleaned, index) => {
|
|
37
|
+
const row = startRow + index;
|
|
38
|
+
if (dropped.has(row)) {
|
|
39
|
+
changed = true;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (cleaned === null || !prefix || !railContent.has(row)) return cleaned;
|
|
43
|
+
const original = stripTerminalSequences(lines[row] ?? "");
|
|
44
|
+
if (!original.startsWith(prefix)) return cleaned;
|
|
45
|
+
// For Starline's own rail the icon may be user text, so preserve it even
|
|
46
|
+
// when upstream would identify it as a pi-tidy decoration.
|
|
47
|
+
if (!starlineOwnsMessages && (cleaned.text !== original || cleaned.leftTrim !== 0)) {
|
|
48
|
+
return cleaned;
|
|
49
|
+
}
|
|
50
|
+
changed = true;
|
|
51
|
+
return { text: original.slice(prefix.length), leftTrim: visibleWidth(prefix) };
|
|
52
|
+
});
|
|
53
|
+
return { rows, changed };
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MouseEventsApi,
|
|
3
|
+
MouseHandlerContext,
|
|
4
|
+
MouseHandlerRegistrationOptions,
|
|
5
|
+
MouseHandlerResult,
|
|
6
|
+
} from "@xzzpig/pi-mouse-events/api";
|
|
7
|
+
|
|
8
|
+
/** v2 publishers before the post-component wheel slot do not declare it. */
|
|
9
|
+
export type SharedWheelHandler = (
|
|
10
|
+
context: MouseHandlerContext & { readonly wheelDelta?: number },
|
|
11
|
+
) => MouseHandlerResult;
|
|
12
|
+
|
|
13
|
+
type WheelRegistrar = (
|
|
14
|
+
handler: SharedWheelHandler,
|
|
15
|
+
options?: MouseHandlerRegistrationOptions,
|
|
16
|
+
) => () => void;
|
|
17
|
+
|
|
18
|
+
/** Read the optional capability without requiring a newer v2 type package. */
|
|
19
|
+
export function sharedWheelRegistrar(api: MouseEventsApi): WheelRegistrar | undefined {
|
|
20
|
+
const candidate = (api as MouseEventsApi & { addWheelHandler?: WheelRegistrar }).addWheelHandler;
|
|
21
|
+
return typeof candidate === "function" ? candidate.bind(api) : undefined;
|
|
22
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The right
|
|
2
|
+
* The hints on the right end of the editor's top rule.
|
|
3
3
|
*
|
|
4
|
-
* Both hints share one slot.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Both hints share one slot. They sat on the bottom border until 0.2.2, then on
|
|
5
|
+
* a metadata row under the text until 0.9, which kept the box a row taller than
|
|
6
|
+
* its text; the top rule holds them without adding a row.
|
|
7
7
|
*/
|
|
8
8
|
export function composeHints(
|
|
9
9
|
pasteHint: string | null,
|