@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
|
@@ -88,11 +88,13 @@ import {
|
|
|
88
88
|
import { activeEditor, pointerOverEditor } from "./editor-mouse";
|
|
89
89
|
import { scrollEditorBy } from "./editor-scroll";
|
|
90
90
|
import { editorVisualRowCount } from "./editor-text-cursor";
|
|
91
|
+
import { nativeCopyHandler } from "./fork-native-clipboard";
|
|
92
|
+
import { cleanForkTranscriptRows } from "./fork-transcript-copy";
|
|
93
|
+
import { type SharedWheelHandler, sharedWheelRegistrar } from "./fork-wheel-compat";
|
|
91
94
|
import { type BoxLike, scrollContentLinesFor } from "./hit-test";
|
|
92
95
|
import { keyTextFor } from "./key-text";
|
|
93
96
|
import { externalEditorName, selectionHintText } from "./selection-state";
|
|
94
97
|
import { type ExpandTarget, expandKeyText, expandTargetAt } from "./tool-box";
|
|
95
|
-
import { cleanTranscriptRows } from "./transcript-copy";
|
|
96
98
|
|
|
97
99
|
/** Pi's interrupt chord — refused by the range-delete branch, never consumed. */
|
|
98
100
|
const CTRL_C = "\x03";
|
|
@@ -275,7 +277,7 @@ export function activeSelectionHintText(): string | null {
|
|
|
275
277
|
);
|
|
276
278
|
} catch {
|
|
277
279
|
// The renderer is best effort; a receiver that has moved on offers no
|
|
278
|
-
// hint rather than breaking the
|
|
280
|
+
// hint rather than breaking the editor's top rule.
|
|
279
281
|
return null;
|
|
280
282
|
}
|
|
281
283
|
}
|
|
@@ -442,11 +444,12 @@ function transcriptSelectionText(
|
|
|
442
444
|
if (receiver.hasOverlay?.()) return undefined;
|
|
443
445
|
const sourceLines = scrollContentLinesFor(receiver.currentLayout?.root, scrollView);
|
|
444
446
|
if (!sourceLines) return undefined;
|
|
445
|
-
const cleaned =
|
|
447
|
+
const cleaned = cleanForkTranscriptRows(
|
|
446
448
|
sourceLines,
|
|
447
449
|
bounds.start.row,
|
|
448
450
|
bounds.end.row,
|
|
449
451
|
config.icons.rail,
|
|
452
|
+
config.features?.editor ?? true,
|
|
450
453
|
);
|
|
451
454
|
if (!cleaned.changed) return undefined;
|
|
452
455
|
const adjusted: SelectionBounds = {
|
|
@@ -729,8 +732,8 @@ function moveCaretForPress(
|
|
|
729
732
|
* which is what `scrollEditorBy` does.
|
|
730
733
|
*
|
|
731
734
|
* The window it scrolls within is Pi's own `max(5, rows * 0.3)`, taken from the
|
|
732
|
-
* terminal rather than from the box's rect: the rect includes Starline's border
|
|
733
|
-
* and
|
|
735
|
+
* terminal rather than from the box's rect: the rect includes Starline's border,
|
|
736
|
+
* padding and filler rows, and guessing how many of those there are would put the
|
|
734
737
|
* boundary in the wrong place. Without a row count there is nothing to compute
|
|
735
738
|
* it from, so the notch falls through.
|
|
736
739
|
*/
|
|
@@ -738,6 +741,7 @@ function scrollEditorForWheel(
|
|
|
738
741
|
receiver: MouseCapableReceiver,
|
|
739
742
|
event: { direction: number; x: number; y: number },
|
|
740
743
|
deps: InstallMouseDeps,
|
|
744
|
+
wheelDelta?: number,
|
|
741
745
|
): boolean {
|
|
742
746
|
try {
|
|
743
747
|
if (!deps.getConfig().mouse.wheelRouting) return false;
|
|
@@ -749,7 +753,10 @@ function scrollEditorForWheel(
|
|
|
749
753
|
const rows = receiver.terminal?.rows;
|
|
750
754
|
if (typeof rows !== "number") return false;
|
|
751
755
|
if (!pointerOverEditorBox(receiver, editor.component, event.x, event.y)) return false;
|
|
752
|
-
const lines =
|
|
756
|
+
const lines =
|
|
757
|
+
typeof wheelDelta === "number" && Number.isFinite(wheelDelta) && wheelDelta !== 0
|
|
758
|
+
? wheelDelta
|
|
759
|
+
: event.direction * Math.max(1, receiver.wheelScrollLines ?? 1);
|
|
753
760
|
return scrollEditorBy(editor.scrollable, lines, rows);
|
|
754
761
|
} catch {
|
|
755
762
|
// Routing is a best-effort read of Pi's internals and of an editor this
|
|
@@ -855,24 +862,22 @@ export function installMouseFeaturesOn(
|
|
|
855
862
|
),
|
|
856
863
|
);
|
|
857
864
|
|
|
865
|
+
const registerWheel = sharedWheelRegistrar(api);
|
|
866
|
+
const wheelHandler: SharedWheelHandler = ({ event, tui, wheelDelta }) => {
|
|
867
|
+
if (event.wheel === undefined) return undefined;
|
|
868
|
+
const receiver = bindReceiver(tui);
|
|
869
|
+
if (!receiver || !featureOn("editorWheelScroll")) return undefined;
|
|
870
|
+
const notch = { direction: event.wheel, x: event.x, y: event.y };
|
|
871
|
+
if (!scrollEditorForWheel(receiver, notch, deps, wheelDelta)) return undefined;
|
|
872
|
+
// The shared wheel slot repaints handled events; the older pre-dispatch
|
|
873
|
+
// slot needs the consumer to request the repaint itself.
|
|
874
|
+
if (!registerWheel) receiver.requestRender?.();
|
|
875
|
+
return { handled: true };
|
|
876
|
+
};
|
|
858
877
|
cleanups.push(
|
|
859
|
-
|
|
860
|
-
({
|
|
861
|
-
|
|
862
|
-
const receiver = bindReceiver(tui);
|
|
863
|
-
if (!receiver || !featureOn("editorWheelScroll")) return undefined;
|
|
864
|
-
const notch = { direction: event.wheel, x: event.x, y: event.y };
|
|
865
|
-
if (!scrollEditorForWheel(receiver, notch, deps)) {
|
|
866
|
-
return undefined;
|
|
867
|
-
}
|
|
868
|
-
// Pi's own wheel routing repaints at the end of every notch;
|
|
869
|
-
// consuming the event means reaching that repaint is now this
|
|
870
|
-
// handler's job.
|
|
871
|
-
receiver.requestRender?.();
|
|
872
|
-
return { handled: true };
|
|
873
|
-
},
|
|
874
|
-
{ priority: 10 },
|
|
875
|
-
),
|
|
878
|
+
registerWheel
|
|
879
|
+
? registerWheel(wheelHandler, { priority: 10 })
|
|
880
|
+
: api.addMouseHandler(wheelHandler, { priority: 10 }),
|
|
876
881
|
);
|
|
877
882
|
|
|
878
883
|
cleanups.push(
|
|
@@ -887,7 +892,32 @@ export function installMouseFeaturesOn(
|
|
|
887
892
|
),
|
|
888
893
|
);
|
|
889
894
|
|
|
890
|
-
if (api.copySlotAvailable) {
|
|
895
|
+
if (api.copySlotAvailable && api.addAsyncCopyHandler) {
|
|
896
|
+
cleanups.push(
|
|
897
|
+
api.addAsyncCopyHandler(
|
|
898
|
+
nativeCopyHandler(({ tui }) => {
|
|
899
|
+
const receiver = bindReceiver(tui);
|
|
900
|
+
const bounds = receiver?.getSelectionBounds?.();
|
|
901
|
+
if (!receiver || !bounds) return undefined;
|
|
902
|
+
const config = deps.getConfig();
|
|
903
|
+
return {
|
|
904
|
+
receiver,
|
|
905
|
+
showNotice: config.mouse.copyNotice,
|
|
906
|
+
texts: [
|
|
907
|
+
() =>
|
|
908
|
+
featureOn("editorBufferCopy")
|
|
909
|
+
? editorTextForSelection(receiver, config, bounds)
|
|
910
|
+
: undefined,
|
|
911
|
+
() =>
|
|
912
|
+
featureOn("transcriptCleanCopy") && config.mouse.transcriptCleanCopy
|
|
913
|
+
? transcriptSelectionText(receiver, config, bounds)
|
|
914
|
+
: undefined,
|
|
915
|
+
],
|
|
916
|
+
};
|
|
917
|
+
}),
|
|
918
|
+
),
|
|
919
|
+
);
|
|
920
|
+
} else if (api.copySlotAvailable) {
|
|
891
921
|
cleanups.push(
|
|
892
922
|
api.addCopyHandler(({ tui }) => {
|
|
893
923
|
const receiver = bindReceiver(tui);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A second reader for pointer events, so more than one part of Starline can see
|
|
3
|
+
* where a click landed without taking a second patch on the same method.
|
|
4
|
+
*
|
|
5
|
+
* The renderer's `handleMouseEvent` is the one method every pointer report goes
|
|
6
|
+
* through before Pi dispatches it to a component or an overlay, and the patch
|
|
7
|
+
* registry holds exactly one behaviour per adapter key (`mouse-pointer-shape`,
|
|
8
|
+
* installed by `pointer-shape.ts`). Rather than register a second adapter on the
|
|
9
|
+
* same method — which would silently replace the first — the patch publishes
|
|
10
|
+
* every event here and whoever needs one subscribes.
|
|
11
|
+
*
|
|
12
|
+
* Observers run before the event is dispatched, so one can close a popover
|
|
13
|
+
* before the click it belongs to reaches the thing underneath, and they never
|
|
14
|
+
* consume anything: whatever the renderer does with the event afterwards is
|
|
15
|
+
* unchanged.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type PointerEventRaw = {
|
|
19
|
+
/** SGR button code: `button & 3` is the button, bit 32 motion, bit 64 wheel. */
|
|
20
|
+
button: number;
|
|
21
|
+
/** Column, 0-based, in screen cells. */
|
|
22
|
+
x: number;
|
|
23
|
+
/** Row, 0-based, from the top of the viewport. */
|
|
24
|
+
y: number;
|
|
25
|
+
/** The `m`/`M` terminator: true ends a gesture. */
|
|
26
|
+
release?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The renderer instance the event is being dispatched on. */
|
|
30
|
+
export type PointerReceiver = {
|
|
31
|
+
terminal?: { rows?: number };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type PointerObserver = (
|
|
35
|
+
event: PointerEventRaw,
|
|
36
|
+
receiver: PointerReceiver | undefined,
|
|
37
|
+
) => void;
|
|
38
|
+
|
|
39
|
+
const observers = new Set<PointerObserver>();
|
|
40
|
+
|
|
41
|
+
/** Watch every pointer report. Returns the disposer. */
|
|
42
|
+
export function observePointerEvents(observer: PointerObserver): () => void {
|
|
43
|
+
observers.add(observer);
|
|
44
|
+
return () => {
|
|
45
|
+
observers.delete(observer);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Publish one event to the observers. Called by the `handleMouseEvent` patch
|
|
51
|
+
* before it dispatches; one observer throwing must not swallow the event for
|
|
52
|
+
* the others or for Pi.
|
|
53
|
+
*/
|
|
54
|
+
export function publishPointerEvent(
|
|
55
|
+
event: PointerEventRaw,
|
|
56
|
+
receiver: PointerReceiver | undefined,
|
|
57
|
+
): void {
|
|
58
|
+
for (const observer of [...observers]) {
|
|
59
|
+
try {
|
|
60
|
+
observer(event, receiver);
|
|
61
|
+
} catch {
|
|
62
|
+
// Observers only observe; a broken one is not the event's problem.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True for a left-or-any-button press: not a release, not a drag, not a notch. */
|
|
68
|
+
export function isPointerPress(event: PointerEventRaw): boolean {
|
|
69
|
+
if (event.release === true) return false;
|
|
70
|
+
if (!Number.isFinite(event.button)) return false;
|
|
71
|
+
// Bit 32 is motion (a drag when a button is held), bit 64 a wheel notch.
|
|
72
|
+
return (event.button & 32) === 0 && (event.button & 64) === 0;
|
|
73
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changes the mouse pointer while it is over something clickable.
|
|
3
|
+
*
|
|
4
|
+
* The terminal draws the pointer, and OSC 22 (`ESC ] 22 ; <shape> ST`, shape names
|
|
5
|
+
* from CSS `cursor`) asks it for a different one; Ghostty and kitty support it, and
|
|
6
|
+
* other terminals ignore it. Pi already forwards every pointer movement to the
|
|
7
|
+
* component under it as a `move` event (any-motion tracking, off under tmux and
|
|
8
|
+
* friends), so the footer knows when the pointer is on a pill.
|
|
9
|
+
*
|
|
10
|
+
* What it cannot know is when the pointer *leaves*: the next `move` goes to
|
|
11
|
+
* whatever is under it now, and Pi has no leave event. So the shape is owned here,
|
|
12
|
+
* around the renderer's own dispatch, not by any component. During each `move` a
|
|
13
|
+
* component may call `requestPointerShape`; after the dispatch, a move that nobody
|
|
14
|
+
* claimed puts the pointer back to `default`. Only a change is written, so moving
|
|
15
|
+
* across a pill costs one escape sequence, not one per cell.
|
|
16
|
+
*
|
|
17
|
+
* This is a stand-in for an upstream API: a `pointer` field on the result a
|
|
18
|
+
* component's `handleMouse` returns, with the renderer writing OSC 22 itself.
|
|
19
|
+
* When Pi ships that, the callers return `{ pointer: "pointer" }` instead and this
|
|
20
|
+
* file goes away.
|
|
21
|
+
*
|
|
22
|
+
* The same patch is the process's only view of every pointer report, so it also
|
|
23
|
+
* publishes each one to `pointer-events` before dispatching. The pill bubble's
|
|
24
|
+
* outside-click dismissal is the second reader; see that module for why it does
|
|
25
|
+
* not get a patch of its own.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { installPrototypePatch } from "../prototype-patch-registry";
|
|
29
|
+
import { requestSharedPointer } from "./fork-pointer-coordination";
|
|
30
|
+
import { type PointerEventRaw, type PointerReceiver, publishPointerEvent } from "./pointer-events";
|
|
31
|
+
|
|
32
|
+
/** CSS cursor names; `default` is the terminal's own arrow. */
|
|
33
|
+
export type PointerShape = "default" | "pointer" | "text";
|
|
34
|
+
|
|
35
|
+
type MouseEventRaw = { button?: unknown; release?: unknown };
|
|
36
|
+
type TerminalHost = { terminal?: { write?: (data: string) => void } };
|
|
37
|
+
type PointerShapeHost = { handleMouseEvent?: unknown };
|
|
38
|
+
|
|
39
|
+
/** SGR bit for "this report is motion", and the button code for "no button held". */
|
|
40
|
+
const MOTION_BIT = 32;
|
|
41
|
+
const NO_BUTTON = 3;
|
|
42
|
+
|
|
43
|
+
let dispatching = false;
|
|
44
|
+
let requested: PointerShape | undefined;
|
|
45
|
+
let current: PointerShape = "default";
|
|
46
|
+
let lastHost: TerminalHost | undefined;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ask for `shape` while the pointer stays where it is. Only meaningful while a
|
|
50
|
+
* `move` is being dispatched; outside one it is ignored, so a click handler that
|
|
51
|
+
* calls it by accident cannot leave the pointer stuck.
|
|
52
|
+
*/
|
|
53
|
+
export function requestPointerShape(shape: PointerShape): void {
|
|
54
|
+
if (requestSharedPointer(shape)) return;
|
|
55
|
+
if (dispatching) requested = shape;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A pointer movement with no button held: what Pi turns into a `move` event. */
|
|
59
|
+
function isHover(raw: MouseEventRaw | undefined): boolean {
|
|
60
|
+
if (!raw || raw.release === true || typeof raw.button !== "number") return false;
|
|
61
|
+
return (raw.button & MOTION_BIT) !== 0 && (raw.button & 0b11) === NO_BUTTON;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeShape(host: TerminalHost | undefined, shape: PointerShape): void {
|
|
65
|
+
try {
|
|
66
|
+
host?.terminal?.write?.(`\x1b]22;${shape}\x1b\\`);
|
|
67
|
+
} catch {
|
|
68
|
+
// A terminal that cannot be written to keeps its pointer; nothing to undo.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Put the pointer back if this module changed it. */
|
|
73
|
+
function resetPointer(): void {
|
|
74
|
+
if (current !== "default") writeShape(lastHost, "default");
|
|
75
|
+
current = "default";
|
|
76
|
+
requested = undefined;
|
|
77
|
+
lastHost = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wrap `TuiAltScreen.handleMouseEvent`, the one method every pointer report passes
|
|
82
|
+
* through before Pi dispatches it. Returns undefined on a build without it — that
|
|
83
|
+
* is a Pi without component mouse events, so there is nothing clickable to point
|
|
84
|
+
* at either.
|
|
85
|
+
*
|
|
86
|
+
* Every report is published to `pointer-events` first, before the shape logic and
|
|
87
|
+
* before Pi dispatches: the pill bubble's outside-click dismissal is a second
|
|
88
|
+
* reader of the same method, and one patch per method is the registry's rule.
|
|
89
|
+
* Nothing about the pointer shape depends on that.
|
|
90
|
+
*/
|
|
91
|
+
export function installPointerShape(prototype: PointerShapeHost): (() => void) | undefined {
|
|
92
|
+
const target = prototype as Record<string, unknown>;
|
|
93
|
+
if (typeof target.handleMouseEvent !== "function") return undefined;
|
|
94
|
+
|
|
95
|
+
const onExit = () => resetPointer();
|
|
96
|
+
let uninstall: () => void;
|
|
97
|
+
try {
|
|
98
|
+
uninstall = installPrototypePatch(
|
|
99
|
+
prototype,
|
|
100
|
+
"handleMouseEvent",
|
|
101
|
+
"mouse-pointer-shape",
|
|
102
|
+
({ predecessor, receiver, args }) => {
|
|
103
|
+
const raw = args[0] as PointerEventRaw | undefined;
|
|
104
|
+
if (raw && Number.isFinite(raw.x) && Number.isFinite(raw.y)) {
|
|
105
|
+
publishPointerEvent(raw, receiver as PointerReceiver);
|
|
106
|
+
}
|
|
107
|
+
if (!isHover(args[0] as MouseEventRaw | undefined)) {
|
|
108
|
+
return Function.prototype.apply.call(predecessor, receiver, args);
|
|
109
|
+
}
|
|
110
|
+
dispatching = true;
|
|
111
|
+
requested = undefined;
|
|
112
|
+
try {
|
|
113
|
+
return Function.prototype.apply.call(predecessor, receiver, args);
|
|
114
|
+
} finally {
|
|
115
|
+
dispatching = false;
|
|
116
|
+
const next = requested ?? "default";
|
|
117
|
+
requested = undefined;
|
|
118
|
+
lastHost = receiver as TerminalHost;
|
|
119
|
+
if (next !== current) {
|
|
120
|
+
current = next;
|
|
121
|
+
writeShape(lastHost, next);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Pi restores the terminal on exit but has never heard of this shape; without
|
|
131
|
+
// the reset a hand left over a pill outlives the process.
|
|
132
|
+
process.once("exit", onExit);
|
|
133
|
+
return () => {
|
|
134
|
+
process.removeListener("exit", onExit);
|
|
135
|
+
resetPointer();
|
|
136
|
+
uninstall();
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -5,19 +5,20 @@
|
|
|
5
5
|
* Pi's `copyActiveSelectionToClipboard` copies *rendered* rows. In the transcript
|
|
6
6
|
* those rows carry chrome that two components paint around their content:
|
|
7
7
|
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
8
|
+
* - pi-tidy's "rail" user messages (`user-message.ts` there; Starline drew
|
|
9
|
+
* them itself before 0.9): a full-width `─` rule above and below, every
|
|
10
|
+
* content row prefixed with `│ ` (`│ hello`), and padding rows that are the
|
|
11
|
+
* bare `│`. With tidy's icons on, the first text row also carries the user
|
|
12
|
+
* icon (`│ hello`) and the rows after it hang two cells further in; both
|
|
13
|
+
* come off too.
|
|
14
|
+
* - pi-tidy's tool box frames (`frame.ts`; `pi-toolbox` before the rename):
|
|
15
|
+
* rounded corners (`╭──╮` / `╰──╯`, the top one possibly labelled, as a
|
|
16
|
+
* "frame" user message's `╭─ ─╮` is) and every content row wrapped in
|
|
17
|
+
* verticals (`│ hello │`).
|
|
14
18
|
* - pi-coding-agent's `DynamicBorder` (bash boxes) paints the same full-width
|
|
15
19
|
* `─` rule — but bash content rows start with a space, not the rail
|
|
16
|
-
* (`bash-execution.js` renders them with paddingX=1), so
|
|
17
|
-
*
|
|
18
|
-
* `copyFriendly` mode (empty rail prefix), where the rule pair alone
|
|
19
|
-
* decides, do its borders come off. Its content is never touched either
|
|
20
|
-
* way.
|
|
20
|
+
* (`bash-execution.js` renders them with paddingX=1), so the pair below never
|
|
21
|
+
* forms and a bash box copies verbatim.
|
|
21
22
|
*
|
|
22
23
|
* The clipboard should hold the content, not the chrome. The frame-free
|
|
23
24
|
* selection task that was cut from 0.3.0 tried to do this by *inferring* where
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
* produced a new edge case — a frame is not a structural concept in Pi. This
|
|
26
27
|
* module therefore recognises only the exact markers above: a rounded-corner
|
|
27
28
|
* pair of equal width, and a full-width rule pair whose every inner row
|
|
28
|
-
* carries the
|
|
29
|
+
* carries the rail. Anything else — a markdown table with
|
|
29
30
|
* its square `┌┬┐` corners, a blockquote's `│ ` gutter, a horizontal rule the
|
|
30
31
|
* assistant actually wrote — is content and passes through byte-identical.
|
|
31
32
|
*
|
|
@@ -53,10 +54,28 @@ export type CleanedRows = {
|
|
|
53
54
|
changed: boolean;
|
|
54
55
|
};
|
|
55
56
|
|
|
56
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* pi-tidy's user message rail. Fixed there (its `USER_RAIL_GLYPH`) so this
|
|
59
|
+
* side can recognise it without reading tidy's settings.
|
|
60
|
+
*/
|
|
61
|
+
const USER_RAIL_GLYPH = "│";
|
|
62
|
+
const USER_RAIL_PREFIX = `${USER_RAIL_GLYPH} `;
|
|
63
|
+
/** pi-tidy's user icon (its `MESSAGE_ICONS.user`), and the indent that hangs under it. */
|
|
64
|
+
const USER_ICON_PREFIX = `${USER_RAIL_PREFIX}\u{f007} `;
|
|
65
|
+
const USER_HANG_PREFIX = `${USER_RAIL_PREFIX} `;
|
|
66
|
+
|
|
67
|
+
/** A content row or a bare padding row of a rail message. */
|
|
68
|
+
function isRailRow(text: string): boolean {
|
|
69
|
+
return text.startsWith(USER_RAIL_PREFIX) || text.trimEnd() === USER_RAIL_GLYPH;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A full-width rule: a rail message's border, `DynamicBorder`. */
|
|
57
73
|
const RULE_ROW_RE = /^─+$/;
|
|
58
|
-
/**
|
|
59
|
-
|
|
74
|
+
/**
|
|
75
|
+
* pi-tidy's rounded frame edges; the top one may carry a label (`╭─ ─╮`,
|
|
76
|
+
* a "frame" user message). Square corners are content (tables).
|
|
77
|
+
*/
|
|
78
|
+
const FRAME_OPEN_RE = /^╭(?:─ [^─]+ )?─*╮$/;
|
|
60
79
|
const FRAME_CLOSE_RE = /^╰─*╯$/;
|
|
61
80
|
|
|
62
81
|
function strippedRow(lines: readonly string[], cache: string[], row: number): string {
|
|
@@ -68,6 +87,37 @@ function strippedRow(lines: readonly string[], cache: string[], row: number): st
|
|
|
68
87
|
return text;
|
|
69
88
|
}
|
|
70
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Decide what each row of one rail message sheds. Padding rows lose the bare
|
|
92
|
+
* rail. When the first text row leads with the user icon, it loses the icon
|
|
93
|
+
* too, and the rows hanging under it lose the indent that lines them up.
|
|
94
|
+
*/
|
|
95
|
+
function claimRailMessage(
|
|
96
|
+
lines: readonly string[],
|
|
97
|
+
cache: string[],
|
|
98
|
+
first: number,
|
|
99
|
+
end: number,
|
|
100
|
+
claimed: Map<number, string>,
|
|
101
|
+
): void {
|
|
102
|
+
let iconed: boolean | undefined;
|
|
103
|
+
for (let row = first; row < end; row++) {
|
|
104
|
+
const text = strippedRow(lines, cache, row);
|
|
105
|
+
if (!text.startsWith(USER_RAIL_PREFIX) || text.trim() === USER_RAIL_GLYPH) {
|
|
106
|
+
claimed.set(row, USER_RAIL_GLYPH);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (iconed === undefined) {
|
|
110
|
+
iconed = text.startsWith(USER_ICON_PREFIX);
|
|
111
|
+
claimed.set(row, iconed ? USER_ICON_PREFIX : USER_RAIL_PREFIX);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
claimed.set(
|
|
115
|
+
row,
|
|
116
|
+
iconed && text.startsWith(USER_HANG_PREFIX) ? USER_HANG_PREFIX : USER_RAIL_PREFIX,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
71
121
|
/**
|
|
72
122
|
* Classifies every row of `lines`, then cleans the rows in
|
|
73
123
|
* `[startRow, endRow]`. The scan covers the whole buffer rather than the
|
|
@@ -79,15 +129,14 @@ export function cleanTranscriptRows(
|
|
|
79
129
|
lines: readonly string[],
|
|
80
130
|
startRow: number,
|
|
81
131
|
endRow: number,
|
|
82
|
-
railGlyph: string,
|
|
83
132
|
): CleanedRows {
|
|
84
133
|
const cache: string[] = new Array(lines.length);
|
|
85
134
|
/** Rows to drop: paired frame edges and paired border rules. */
|
|
86
135
|
const dropped = new Set<number>();
|
|
87
136
|
/** pi-toolbox frame content rows: strip one `│` from each side. */
|
|
88
137
|
const frameContent = new Set<number>();
|
|
89
|
-
/**
|
|
90
|
-
const railContent = new
|
|
138
|
+
/** Rail user message rows, and the prefix each one sheds. */
|
|
139
|
+
const railContent = new Map<number, string>();
|
|
91
140
|
|
|
92
141
|
// Pass 1 — rounded frames. Edges pair strictly by equal width, inner rows
|
|
93
142
|
// are content. A rounded box drawn inside bash output can mis-pair, but the
|
|
@@ -113,17 +162,10 @@ export function cleanTranscriptRows(
|
|
|
113
162
|
}
|
|
114
163
|
|
|
115
164
|
// Pass 2 — full-width rule pairs. Two rules of equal width with at least
|
|
116
|
-
// one row between them are a
|
|
117
|
-
// between carry the rail. Any inner row *without* the rail
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// With an empty rail prefix (the `copyFriendly` render drops the rail)
|
|
122
|
-
// nothing can break a candidate, so any two equal-width rules pair. That
|
|
123
|
-
// also matches a markdown `---` pair, which then loses its rules from the
|
|
124
|
-
// copy — accepted: a horizontal rule is closer to chrome than to content,
|
|
125
|
-
// and the alternative is leaving real borders in every copyFriendly copy.
|
|
126
|
-
const railPrefix = railGlyph.length > 0 ? `${railGlyph} ` : "";
|
|
165
|
+
// one row between them are a rail user message's borders, and the rows
|
|
166
|
+
// between carry the rail. Any inner row *without* the rail breaks the
|
|
167
|
+
// candidate: it is what keeps an assistant's own `---` rules, and the text
|
|
168
|
+
// between two of them, from being eaten — those rows are content.
|
|
127
169
|
let lastRule: { row: number; width: number } | undefined;
|
|
128
170
|
for (let row = 0; row < lines.length; row++) {
|
|
129
171
|
if (dropped.has(row)) continue;
|
|
@@ -133,10 +175,10 @@ export function cleanTranscriptRows(
|
|
|
133
175
|
if (lastRule && lastRule.width === width && row - lastRule.row >= 2) {
|
|
134
176
|
dropped.add(lastRule.row);
|
|
135
177
|
dropped.add(row);
|
|
136
|
-
|
|
178
|
+
claimRailMessage(lines, cache, lastRule.row + 1, row, railContent);
|
|
137
179
|
}
|
|
138
180
|
lastRule = { row, width };
|
|
139
|
-
} else if (
|
|
181
|
+
} else if (!isRailRow(text)) {
|
|
140
182
|
lastRule = undefined;
|
|
141
183
|
}
|
|
142
184
|
}
|
|
@@ -155,8 +197,9 @@ export function cleanTranscriptRows(
|
|
|
155
197
|
changed = true;
|
|
156
198
|
continue;
|
|
157
199
|
}
|
|
158
|
-
|
|
159
|
-
|
|
200
|
+
const prefix = railContent.get(row);
|
|
201
|
+
if (prefix !== undefined && text.startsWith(prefix)) {
|
|
202
|
+
rows.push({ text: text.slice(prefix.length), leftTrim: visibleWidth(prefix) });
|
|
160
203
|
changed = true;
|
|
161
204
|
continue;
|
|
162
205
|
}
|
|
@@ -63,6 +63,39 @@ export function supportsPasteCollapse(value: unknown): boolean {
|
|
|
63
63
|
return asPasteInternals(value) !== null;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Find an editor method through [[GetOwnProperty]] instead of [[Get]].
|
|
68
|
+
*
|
|
69
|
+
* What we are handed is not always the editor. Another extension may pass us its
|
|
70
|
+
* own Proxy over it — `pi-better-background-tasks` answers `handleInput` with a
|
|
71
|
+
* wrapper its get trap builds on the spot, and forwards writes to the editor
|
|
72
|
+
* behind. Capturing through [[Get]] then captures *that* wrapper, and the
|
|
73
|
+
* `defineProperty` below shadows the very property the wrapper dispatches
|
|
74
|
+
* through, so the two call each other with nothing underneath: one keystroke and
|
|
75
|
+
* the stack is gone. Descriptor reads go to the target, so this finds the real
|
|
76
|
+
* handler whoever's Proxy is standing in front of it.
|
|
77
|
+
*/
|
|
78
|
+
function resolveHandler<Signature extends (...args: never[]) => unknown>(
|
|
79
|
+
editor: object,
|
|
80
|
+
prop: string,
|
|
81
|
+
): Signature | undefined {
|
|
82
|
+
// An instance handler first: whoever put it there is upstream of us, and Pi
|
|
83
|
+
// itself keeps its handlers on the prototype, where the walk below finds them.
|
|
84
|
+
const own = Object.getOwnPropertyDescriptor(editor, prop);
|
|
85
|
+
if (own && "value" in own && typeof own.value === "function") return own.value as Signature;
|
|
86
|
+
for (
|
|
87
|
+
let proto: object | null = Object.getPrototypeOf(editor);
|
|
88
|
+
proto !== null;
|
|
89
|
+
proto = Object.getPrototypeOf(proto)
|
|
90
|
+
) {
|
|
91
|
+
const desc = Object.getOwnPropertyDescriptor(proto, prop);
|
|
92
|
+
if (desc && "value" in desc && typeof desc.value === "function") {
|
|
93
|
+
return desc.value as Signature;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
66
99
|
/**
|
|
67
100
|
* Reproduce Pi's pre-collapse cleaning: decode the CSI-u control re-encoding
|
|
68
101
|
* some terminals apply inside a bracketed paste, normalise line endings and
|
|
@@ -174,7 +207,7 @@ export function installPasteCollapse(
|
|
|
174
207
|
const editor = asPasteInternals(value);
|
|
175
208
|
if (!editor) return undefined;
|
|
176
209
|
|
|
177
|
-
const base = editor
|
|
210
|
+
const base = resolveHandler<(text: string) => void>(editor, "handlePaste");
|
|
178
211
|
if (typeof base !== "function") return undefined;
|
|
179
212
|
|
|
180
213
|
// Pi keeps handlePaste on the prototype, where deleting our shadow reveals it
|
|
@@ -260,17 +293,16 @@ export function installPasteCollapse(
|
|
|
260
293
|
|
|
261
294
|
// Anything that is not a paste puts the offer away. Pi feeds a bracketed paste
|
|
262
295
|
// in over several calls with isInPaste set, so only the tail of one counts.
|
|
263
|
-
const baseInput = editor
|
|
296
|
+
const baseInput = resolveHandler<(data: string) => unknown>(editor, "handleInput");
|
|
264
297
|
const previousOwnInput = Object.getOwnPropertyDescriptor(editor, "handleInput");
|
|
265
|
-
const inputShadow =
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
: null;
|
|
298
|
+
const inputShadow = baseInput
|
|
299
|
+
? function (this: unknown, data: string): unknown {
|
|
300
|
+
sawPaste = false;
|
|
301
|
+
const result = baseInput.call(editor, data);
|
|
302
|
+
if (!sawPaste && editor.isInPaste !== true) armed = null;
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
: null;
|
|
274
306
|
if (inputShadow) {
|
|
275
307
|
Object.defineProperty(editor, "handleInput", {
|
|
276
308
|
configurable: true,
|
|
@@ -292,17 +324,24 @@ export function installPasteCollapse(
|
|
|
292
324
|
};
|
|
293
325
|
const ownHint = activeHint;
|
|
294
326
|
|
|
327
|
+
/** Read back where the shadows were put. `[[Get]]` is the wrong question to ask
|
|
328
|
+
* an editor that came wrapped in another extension's Proxy: its get trap
|
|
329
|
+
* answers `handleInput` with a wrapper of its own, so the comparison below
|
|
330
|
+
* never recognises our shadow and the disposer leaves it installed. */
|
|
331
|
+
const ownValue = (prop: "handleInput" | "handlePaste"): unknown =>
|
|
332
|
+
Object.getOwnPropertyDescriptor(editor, prop)?.value;
|
|
333
|
+
|
|
295
334
|
return () => {
|
|
296
335
|
armed = null;
|
|
297
336
|
if (activeHint === ownHint) activeHint = null;
|
|
298
|
-
if (inputShadow && (
|
|
337
|
+
if (inputShadow && ownValue("handleInput") === inputShadow) {
|
|
299
338
|
if (previousOwnInput) {
|
|
300
339
|
Object.defineProperty(editor, "handleInput", previousOwnInput);
|
|
301
340
|
} else {
|
|
302
341
|
delete (editor as { handleInput?: unknown }).handleInput;
|
|
303
342
|
}
|
|
304
343
|
}
|
|
305
|
-
if ((
|
|
344
|
+
if (ownValue("handlePaste") !== shadow) return;
|
|
306
345
|
if (previousOwn) {
|
|
307
346
|
Object.defineProperty(editor, "handlePaste", previousOwn);
|
|
308
347
|
return;
|