@xzzpig/pi-starline 0.5.0 → 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.
Files changed (53) hide show
  1. package/FORK-CHANGELOG.md +28 -0
  2. package/README.md +91 -181
  3. package/dist/public.d.ts +5 -0
  4. package/extensions/starline/config.ts +156 -326
  5. package/extensions/starline/context-live.ts +33 -0
  6. package/extensions/starline/editor-cursor.ts +3 -0
  7. package/extensions/starline/extension-status.ts +9 -5
  8. package/extensions/starline/footer.ts +331 -528
  9. package/extensions/starline/fork-editor-cursor.ts +14 -0
  10. package/extensions/starline/format.ts +151 -20
  11. package/extensions/starline/git-live.ts +20 -0
  12. package/extensions/starline/git-probe.ts +40 -0
  13. package/extensions/starline/git-segments.ts +175 -0
  14. package/extensions/starline/git.ts +71 -0
  15. package/extensions/starline/icons.ts +17 -0
  16. package/extensions/starline/index.ts +188 -91
  17. package/extensions/starline/live-session.ts +164 -0
  18. package/extensions/starline/live-tps.ts +165 -0
  19. package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
  20. package/extensions/starline/mouse/cursor-marker.ts +69 -0
  21. package/extensions/starline/mouse/editor-hit-test.ts +6 -3
  22. package/extensions/starline/mouse/explicit-expand-target.ts +89 -0
  23. package/extensions/starline/mouse/external-layer.ts +42 -0
  24. package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
  25. package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
  26. package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
  27. package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
  28. package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
  29. package/extensions/starline/mouse/hint.ts +4 -4
  30. package/extensions/starline/mouse/index.ts +54 -24
  31. package/extensions/starline/mouse/pointer-events.ts +73 -0
  32. package/extensions/starline/mouse/pointer-shape.ts +138 -0
  33. package/extensions/starline/mouse/tool-box.ts +4 -0
  34. package/extensions/starline/mouse/transcript-copy.ts +76 -33
  35. package/extensions/starline/paste-collapse.ts +52 -13
  36. package/extensions/starline/pill-actions.ts +590 -0
  37. package/extensions/starline/pill-anchors.ts +26 -0
  38. package/extensions/starline/pill-api.ts +80 -0
  39. package/extensions/starline/pill-bubble.ts +607 -0
  40. package/extensions/starline/pill-config.ts +67 -9
  41. package/extensions/starline/pill.ts +102 -20
  42. package/extensions/starline/project-probe.ts +79 -0
  43. package/extensions/starline/prototype-patch-registry.ts +6 -1
  44. package/extensions/starline/queue-rows.ts +43 -0
  45. package/extensions/starline/settings-command.ts +124 -177
  46. package/extensions/starline/subagent-fleet.ts +246 -0
  47. package/extensions/starline/turn-clock.ts +81 -0
  48. package/extensions/starline/ui.ts +297 -156
  49. package/extensions/starline/working-indicator.ts +189 -0
  50. package/package.json +18 -12
  51. package/types/pill-api.d.ts +115 -0
  52. package/extensions/starline/editor-metadata-format.ts +0 -259
  53. 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, [pad], metadata, rule
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 metadata row, then the rule.
76
- const lastTextRow = bottom - 2 - pad;
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,89 @@
1
+ /**
2
+ * Fork-only consumer for the versioned explicit expand-target protocol
3
+ * (`@xzzpig/transcript-expand-target.v1`).
4
+ *
5
+ * Providers (pi-group-collapse today) publish a resolver on the component
6
+ * instance that draws a group summary. Starline checks that resolver for
7
+ * the component's *local* row before its own `setExpanded`/hint logic, so a
8
+ * click on a summary toggles the group object instead of the first tool
9
+ * inside it.
10
+ *
11
+ * The contract is defensive by requirement (see the mouse-api spec):
12
+ * no published protocol, a wrong version, a non-callable resolver, a
13
+ * thrown resolution, or an invalid resolved shape all fall back to the
14
+ * existing component path — installing the provider is never required and
15
+ * a broken provider cannot break clicks that used to work.
16
+ *
17
+ * This module deliberately depends on nothing: the symbol key is its only
18
+ * tie to the provider, so Starline keeps working with the provider absent,
19
+ * and any number of providers can publish the same key.
20
+ */
21
+
22
+ /** The shared protocol key; `Symbol.for` so every copy in the process agrees. */
23
+ export const EXPAND_TARGET_KEY = Symbol.for("@xzzpig/transcript-expand-target.v1");
24
+
25
+ /** The slice of the protocol Starline reads. */
26
+ type ExpandTargetProtocol = {
27
+ version: 1;
28
+ resolveLocalTarget: (localRow: number, width: number) => unknown;
29
+ };
30
+
31
+ /** The resolved target, validated before use. */
32
+ export type ExplicitExpandTarget = {
33
+ component: { setExpanded(expanded: boolean): void };
34
+ expanded: boolean;
35
+ };
36
+
37
+ /**
38
+ * Resolve an explicit expand target for a component's local row, or
39
+ * undefined when there is no provider, no hit, or anything invalid.
40
+ */
41
+ export function explicitExpandTargetAt(
42
+ component: unknown,
43
+ localRow: number,
44
+ width: number,
45
+ ): ExplicitExpandTarget | undefined {
46
+ if (typeof component !== "object" || component === null) return undefined;
47
+ let protocol: unknown;
48
+ try {
49
+ protocol = (component as Record<PropertyKey, unknown>)[EXPAND_TARGET_KEY];
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ if (
54
+ typeof protocol !== "object" ||
55
+ protocol === null ||
56
+ (protocol as ExpandTargetProtocol).version !== 1 ||
57
+ typeof (protocol as ExpandTargetProtocol).resolveLocalTarget !== "function"
58
+ ) {
59
+ return undefined;
60
+ }
61
+ let resolved: unknown;
62
+ try {
63
+ resolved = (protocol as ExpandTargetProtocol).resolveLocalTarget(localRow, width);
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ if (
68
+ typeof resolved !== "object" ||
69
+ resolved === null ||
70
+ typeof (resolved as ExplicitExpandTarget).expanded !== "boolean"
71
+ ) {
72
+ return undefined;
73
+ }
74
+ const target = resolved as {
75
+ component?: { setExpanded?: unknown };
76
+ expanded: boolean;
77
+ };
78
+ if (
79
+ typeof target.component !== "object" ||
80
+ target.component === null ||
81
+ typeof target.component.setExpanded !== "function"
82
+ ) {
83
+ return undefined;
84
+ }
85
+ return {
86
+ component: target.component as { setExpanded(expanded: boolean): void },
87
+ expanded: target.expanded,
88
+ };
89
+ }
@@ -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-hand side of the editor's metadata row.
2
+ * The hints on the right end of the editor's top rule.
3
3
  *
4
- * Both hints share one slot. `ui.ts` renders that row whether or not
5
- * `editorMetadataFormat` has anything in it, which is why 0.2.2 moved the paste
6
- * hint here off the editor border — blanking the template must not hide it.
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,