@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.
Files changed (50) hide show
  1. package/FORK-CHANGELOG.md +19 -0
  2. package/README.md +68 -181
  3. package/extensions/starline/config.ts +156 -326
  4. package/extensions/starline/context-live.ts +33 -0
  5. package/extensions/starline/editor-cursor.ts +3 -0
  6. package/extensions/starline/extension-status.ts +9 -5
  7. package/extensions/starline/footer.ts +331 -528
  8. package/extensions/starline/fork-editor-cursor.ts +14 -0
  9. package/extensions/starline/format.ts +151 -20
  10. package/extensions/starline/git-live.ts +20 -0
  11. package/extensions/starline/git-probe.ts +40 -0
  12. package/extensions/starline/git-segments.ts +175 -0
  13. package/extensions/starline/git.ts +71 -0
  14. package/extensions/starline/icons.ts +17 -0
  15. package/extensions/starline/index.ts +188 -91
  16. package/extensions/starline/live-session.ts +164 -0
  17. package/extensions/starline/live-tps.ts +165 -0
  18. package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
  19. package/extensions/starline/mouse/cursor-marker.ts +69 -0
  20. package/extensions/starline/mouse/editor-hit-test.ts +6 -3
  21. package/extensions/starline/mouse/external-layer.ts +42 -0
  22. package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
  23. package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
  24. package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
  25. package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
  26. package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
  27. package/extensions/starline/mouse/hint.ts +4 -4
  28. package/extensions/starline/mouse/index.ts +54 -24
  29. package/extensions/starline/mouse/pointer-events.ts +73 -0
  30. package/extensions/starline/mouse/pointer-shape.ts +138 -0
  31. package/extensions/starline/mouse/transcript-copy.ts +76 -33
  32. package/extensions/starline/paste-collapse.ts +52 -13
  33. package/extensions/starline/pill-actions.ts +590 -0
  34. package/extensions/starline/pill-anchors.ts +26 -0
  35. package/extensions/starline/pill-api.ts +80 -0
  36. package/extensions/starline/pill-bubble.ts +607 -0
  37. package/extensions/starline/pill-config.ts +67 -9
  38. package/extensions/starline/pill.ts +102 -20
  39. package/extensions/starline/project-probe.ts +79 -0
  40. package/extensions/starline/prototype-patch-registry.ts +6 -1
  41. package/extensions/starline/queue-rows.ts +43 -0
  42. package/extensions/starline/settings-command.ts +124 -177
  43. package/extensions/starline/subagent-fleet.ts +246 -0
  44. package/extensions/starline/turn-clock.ts +81 -0
  45. package/extensions/starline/ui.ts +297 -156
  46. package/extensions/starline/working-indicator.ts +189 -0
  47. package/package.json +6 -5
  48. package/types/pill-api.d.ts +115 -0
  49. package/extensions/starline/editor-metadata-format.ts +0 -259
  50. 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 metadata row.
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 = cleanTranscriptRows(
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 metadata rows, and guessing how many of those there are would put the
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 = event.direction * Math.max(1, receiver.wheelScrollLines ?? 1);
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
- api.addMouseHandler(
860
- ({ event, tui }) => {
861
- if (event.wheel === undefined) return undefined;
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
- * - Starline's own user messages (`user-message.ts`): a full-width `─` rule
9
- * above and below, and every content row prefixed with the configured rail
10
- * glyph plus a space (`│ hello`).
11
- * - `pi-toolbox`'s tool box frames (`frame.ts`): rounded corners
12
- * (`╭──╮` / `╰──╯`) and every content row wrapped in verticals
13
- * (`│ hello │`).
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 under the default
17
- * rail the pair below never forms and a bash box copies verbatim. Only in
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 configured rail prefix. Anything else — a markdown table with
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
- /** A full-width rule: Starline's user message border, `DynamicBorder`. */
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
- /** pi-toolbox's rounded frame edges. Square corners are content (tables). */
59
- const FRAME_OPEN_RE = /^╭─*╮$/;
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
- /** Starline user message rows: strip the rail prefix. */
90
- const railContent = new Set<number>();
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 Starline user message's borders, and the rows
117
- // between carry the rail. Any inner row *without* the rail prefix breaks
118
- // the candidate: it is what keeps an assistant's own `---` rules, and the
119
- // text between two of them, from being eaten — those rows are content.
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
- for (let inner = lastRule.row + 1; inner < row; inner++) railContent.add(inner);
178
+ claimRailMessage(lines, cache, lastRule.row + 1, row, railContent);
137
179
  }
138
180
  lastRule = { row, width };
139
- } else if (railPrefix.length > 0 && !text.startsWith(railPrefix)) {
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
- if (railContent.has(row) && railPrefix.length > 0 && text.startsWith(railPrefix)) {
159
- rows.push({ text: text.slice(railPrefix.length), leftTrim: visibleWidth(railPrefix) });
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.handlePaste;
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.handleInput;
296
+ const baseInput = resolveHandler<(data: string) => unknown>(editor, "handleInput");
264
297
  const previousOwnInput = Object.getOwnPropertyDescriptor(editor, "handleInput");
265
- const inputShadow =
266
- typeof baseInput === "function"
267
- ? function (this: unknown, data: string): unknown {
268
- sawPaste = false;
269
- const result = baseInput.call(editor, data);
270
- if (!sawPaste && editor.isInPaste !== true) armed = null;
271
- return result;
272
- }
273
- : null;
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 && (editor as { handleInput?: unknown }).handleInput === 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 ((editor as { handlePaste?: unknown }).handlePaste !== shadow) return;
344
+ if (ownValue("handlePaste") !== shadow) return;
306
345
  if (previousOwn) {
307
346
  Object.defineProperty(editor, "handlePaste", previousOwn);
308
347
  return;