@xzzpig/pi-tidy 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +1104 -0
  2. package/FORK-CHANGELOG.md +31 -0
  3. package/LICENSE +21 -0
  4. package/README.md +416 -2
  5. package/fork/defaults.ts +77 -0
  6. package/fork/index.ts +2 -0
  7. package/fork/install.ts +58 -0
  8. package/fork/thinking-row.ts +221 -0
  9. package/package.json +68 -3
  10. package/src/clean-copy.ts +313 -0
  11. package/src/clipboard.ts +29 -0
  12. package/src/config.ts +292 -0
  13. package/src/draw/frame-draw.ts +307 -0
  14. package/src/draw/icons.ts +2014 -0
  15. package/src/draw/paint.ts +57 -0
  16. package/src/frame.ts +20 -0
  17. package/src/hover.ts +185 -0
  18. package/src/index.ts +322 -0
  19. package/src/live.ts +91 -0
  20. package/src/messages/bash-mode-frame.ts +112 -0
  21. package/src/messages/error-lines.ts +331 -0
  22. package/src/messages/extension-notice.ts +204 -0
  23. package/src/messages/hex-swatches.ts +245 -0
  24. package/src/messages/message-frame.ts +515 -0
  25. package/src/messages/user-message.ts +207 -0
  26. package/src/theme-access.ts +26 -0
  27. package/src/tools/background-jobs.ts +617 -0
  28. package/src/tools/bash-highlight.ts +330 -0
  29. package/src/tools/codemode-body.ts +602 -0
  30. package/src/tools/result-preview.ts +56 -0
  31. package/src/tools/split-diff.ts +606 -0
  32. package/src/tools/tool-frame.ts +1019 -0
  33. package/src/tools/tool-group-view.ts +1160 -0
  34. package/src/tools/tool-groups.ts +706 -0
  35. package/src/tools/tool-lines.ts +621 -0
  36. package/src/tools/write-preview.ts +81 -0
  37. package/src/turns/code-blocks.ts +273 -0
  38. package/src/turns/turn-lifecycle.ts +43 -0
  39. package/src/turns/turn-state.ts +257 -0
  40. package/src/turns/turn-view.ts +504 -0
@@ -0,0 +1,221 @@
1
+ import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
2
+ import { MouseRegion, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { tidyEnabled } from "../src/live.ts";
4
+ import {
5
+ messageKey,
6
+ turnFor,
7
+ turnOptions,
8
+ type TurnMessage,
9
+ } from "../src/turns/turn-state.ts";
10
+
11
+ const GROUP_BOUNDARY = Symbol.for("pi-toolbox:group-boundary");
12
+ const TURN_MOUSE = Symbol.for("pi-tidy:turn-mouse");
13
+ const FORK_RENDER = Symbol.for("pi-tidy:fork-thinking-row-render");
14
+ const FORK_MOUSE = Symbol.for("pi-tidy:fork-thinking-row-mouse");
15
+
16
+ interface ThinkingRow {
17
+ region: MouseRegion;
18
+ start: number;
19
+ height: number;
20
+ }
21
+
22
+ interface ThinkingRows {
23
+ lines: string[];
24
+ entries: ThinkingRow[];
25
+ }
26
+
27
+ const rowsByComponent = new WeakMap<AssistantMessageComponent, ThinkingRows>();
28
+ const wrappedMouseHandlers = new WeakSet<Function>();
29
+
30
+ interface AssistantInternals {
31
+ lastMessage: unknown;
32
+ contentContainer: { children: unknown[] };
33
+ hideThinkingBlock: boolean;
34
+ }
35
+
36
+ function assistantInternals(
37
+ component: AssistantMessageComponent,
38
+ ): AssistantInternals {
39
+ // SAFETY: Pi 1.5 declares these renderer fields private, but its own final-reply renderer reads the same runtime fields.
40
+ return component as unknown as AssistantInternals;
41
+ }
42
+
43
+ function isRecord(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === "object" && value !== null;
45
+ }
46
+
47
+ function isTurnMessage(value: unknown): value is TurnMessage {
48
+ return (
49
+ isRecord(value) &&
50
+ typeof value.role === "string" &&
51
+ Array.isArray(value.content)
52
+ );
53
+ }
54
+
55
+ function isThinkingBlock(
56
+ value: unknown,
57
+ ): value is { type: "thinking"; thinking: string } {
58
+ return (
59
+ isRecord(value) &&
60
+ value.type === "thinking" &&
61
+ typeof value.thinking === "string" &&
62
+ value.thinking.trim().length > 0
63
+ );
64
+ }
65
+
66
+ function readLastMessage(
67
+ component: AssistantMessageComponent,
68
+ ): TurnMessage | undefined {
69
+ const message = assistantInternals(component).lastMessage;
70
+ return isTurnMessage(message) ? message : undefined;
71
+ }
72
+
73
+ function hasThinking(message: TurnMessage | undefined): boolean {
74
+ return Boolean(message?.content.some(isThinkingBlock));
75
+ }
76
+
77
+ function isFramedFinalReply(
78
+ component: AssistantMessageComponent,
79
+ ): boolean {
80
+ const message = readLastMessage(component);
81
+ const key = messageKey(message);
82
+ const turn = turnFor(message);
83
+ return Boolean(
84
+ tidyEnabled.get() &&
85
+ turnOptions.get().frameFinalReply &&
86
+ key &&
87
+ turn?.final === key &&
88
+ hasThinking(message),
89
+ );
90
+ }
91
+
92
+ function childComponents(
93
+ component: AssistantMessageComponent,
94
+ ): unknown[] {
95
+ return assistantInternals(component).contentContainer.children;
96
+ }
97
+
98
+ function isMouseRegion(value: unknown): value is MouseRegion {
99
+ if (value instanceof MouseRegion) return true;
100
+ if (!isRecord(value)) return false;
101
+ return (
102
+ typeof value.constructor === "function" &&
103
+ value.constructor.name === "MouseRegion" &&
104
+ typeof value.render === "function" &&
105
+ typeof value.handleMouse === "function"
106
+ );
107
+ }
108
+
109
+ function thinkingRegions(component: AssistantMessageComponent): MouseRegion[] {
110
+ return childComponents(component).filter(isMouseRegion);
111
+ }
112
+
113
+ function hasHiddenThinking(component: AssistantMessageComponent): boolean {
114
+ return assistantInternals(component).hideThinkingBlock === true;
115
+ }
116
+
117
+ function buildThinkingRows(
118
+ component: AssistantMessageComponent,
119
+ width: number,
120
+ ): ThinkingRows | undefined {
121
+ // pi-tidy's framed-final-reply path intentionally omits thinking children.
122
+ // Restore only the settled, hidden Pi MouseRegions: streaming output remains
123
+ // owned by Pi and the row keeps Pi's own click-to-expand behavior.
124
+ if (
125
+ !isFramedFinalReply(component) ||
126
+ !hasHiddenThinking(component)
127
+ ) {
128
+ return undefined;
129
+ }
130
+
131
+ const lines: string[] = [];
132
+ const entries: ThinkingRow[] = [];
133
+ for (const region of thinkingRegions(component)) {
134
+ const row = region.render(width);
135
+ if (row.length === 0) continue;
136
+ if (lines.length > 0) lines.push("");
137
+ entries.push({ region, start: lines.length, height: row.length });
138
+ lines.push(...row);
139
+ }
140
+ return entries.length > 0 ? { lines, entries } : undefined;
141
+ }
142
+
143
+ function installMouseAdapter(component: AssistantMessageComponent): void {
144
+ const previous = component.handleMouse;
145
+ if (typeof previous !== "function" || wrappedMouseHandlers.has(previous)) return;
146
+
147
+ const wrapped = function (
148
+ this: AssistantMessageComponent,
149
+ event: TuiMouseEvent,
150
+ ): ReturnType<AssistantMessageComponent["handleMouse"]> {
151
+ const rows = rowsByComponent.get(this);
152
+ if (!rows) return previous.call(this, event);
153
+
154
+ const row = rows.entries.find(
155
+ (entry) => event.y >= entry.start && event.y < entry.start + entry.height,
156
+ );
157
+ if (row) {
158
+ const rowEvent = {
159
+ ...event,
160
+ y: event.y - row.start,
161
+ height: row.height,
162
+ };
163
+ const result = row.region.handleMouse(rowEvent);
164
+ if (!result?.handled) return undefined;
165
+ if ("target" in result) return result;
166
+ return {
167
+ ...result,
168
+ handled: true,
169
+ target: {
170
+ component: row.region,
171
+ originX: 0,
172
+ originY: row.start,
173
+ width: event.width,
174
+ height: row.height,
175
+ },
176
+ };
177
+ }
178
+
179
+ if (event.y >= rows.lines.length) {
180
+ return previous.call(this, {
181
+ ...event,
182
+ y: event.y - rows.lines.length,
183
+ height: Math.max(0, event.height - rows.lines.length),
184
+ });
185
+ }
186
+ return undefined;
187
+ };
188
+
189
+ wrappedMouseHandlers.add(wrapped);
190
+ Object.defineProperty(wrapped, FORK_MOUSE, { value: true });
191
+ // Let pi-tidy's own renderer keep this outer wrapper on subsequent renders.
192
+ Object.defineProperty(wrapped, TURN_MOUSE, { value: true });
193
+ component.handleMouse = wrapped;
194
+ }
195
+
196
+ /** Install after pi-tidy's boundary renderer so its final-frame suppression can be composed. */
197
+ export function installForkThinkingRows(): void {
198
+ const prototype = AssistantMessageComponent.prototype;
199
+ const previous = prototype.render;
200
+ if (typeof previous !== "function" || Object.hasOwn(previous, FORK_RENDER)) return;
201
+
202
+ const wrapped = function (
203
+ this: AssistantMessageComponent,
204
+ width: number,
205
+ ): string[] {
206
+ const rendered = previous.call(this, width);
207
+ const rows = buildThinkingRows(this, width);
208
+ if (!rows || rendered.length === 0) {
209
+ rowsByComponent.delete(this);
210
+ return rendered;
211
+ }
212
+ rowsByComponent.set(this, rows);
213
+ installMouseAdapter(this);
214
+ return [...rows.lines, ...rendered];
215
+ };
216
+
217
+ Object.defineProperty(wrapped, FORK_RENDER, { value: true });
218
+ // pi-tidy's installGroupBoundaries checks this marker before wrapping again.
219
+ Object.defineProperty(wrapped, GROUP_BOUNDARY, { value: true });
220
+ prototype.render = wrapped;
221
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@xzzpig/pi-tidy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Tool folding for the pi agent — a reply's tool calls collapse to one clickable line; failures, edit diffs and running jobs stay visible",
6
+ "license": "MIT",
7
+ "author": "Andy8647",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "transcript",
12
+ "tool-calls",
13
+ "tool-folding",
14
+ "claude-code",
15
+ "pi-processes",
16
+ "tool-rendering",
17
+ "mcp",
18
+ "syntax-highlighting"
19
+ ],
20
+ "files": [
21
+ "src",
22
+ "fork",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "FORK-CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
28
+ "main": "./src/index.ts",
29
+ "types": "./src/index.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./src/index.ts",
33
+ "default": "./src/index.ts"
34
+ }
35
+ },
36
+ "pi": {
37
+ "extensions": [
38
+ "./src/index.ts"
39
+ ],
40
+ "image": "https://raw.githubusercontent.com/Andy8647/pi-tidy/main/assets/cover.png",
41
+ "video": "https://cdn.jsdelivr.net/gh/Andy8647/pi-tidy@v0.4.3/assets/demo.mp4"
42
+ },
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*"
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "^1.5.0",
52
+ "@earendil-works/pi-tui": "^1.5.0",
53
+ "@types/node": "^22.0.0",
54
+ "tsx": "^4.22.4",
55
+ "typescript": "^5.9.0"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/xzzpig/pi-extensions.git"
60
+ },
61
+ "homepage": "https://github.com/Andy8647/pi-tidy#readme",
62
+ "bugs": {
63
+ "url": "https://github.com/Andy8647/pi-tidy/issues"
64
+ },
65
+ "scripts": {
66
+ "test": "node --test test/*.test.ts test/fork/*.test.ts test/renderer-harness.ts",
67
+ "test:e2e": "tsx --test test/e2e/*.e2e.ts",
68
+ "typecheck": "tsc --noEmit",
69
+ "bench": "node scripts/bench-compare.ts"
70
+ }
6
71
  }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Takes tidy's chrome out of a transcript selection before it reaches the
3
+ * clipboard.
4
+ *
5
+ * pi copies rendered rows, so a selection in a frame brings the frame along:
6
+ * `│ hello │`. Both of pi's copies (select-to-copy and the copy key) read
7
+ * `getActiveSelectionText`, so wrapping that one method covers them. The native
8
+ * selection a terminal does itself (shift or option and drag) never comes here.
9
+ *
10
+ * Only tidy's exact shapes are recognised:
11
+ *
12
+ * - A rounded frame: a pair of equal-width edges in one column, every row between
13
+ * them wrapped in verticals. A corner may be a `├` where it meets a rail, and the
14
+ * frame may hang on rails (`│ ╭──╮`). All of that comes off, with the one-cell
15
+ * inset when every row has it. The label on the top edge is the exception when it
16
+ * names a file: the path a tool box puts there is what the selection was for, so it
17
+ * stays while the corner, the padding rules, the title's icons and its copy chip
18
+ * come off.
19
+ * - A `rail` user message: two equal `─` rules, the top one possibly labelled, whose
20
+ * every row between them carries the rail.
21
+ *
22
+ * Anything else is content and stays byte-identical: a table's square corners, a
23
+ * blockquote's gutter, a rule the assistant wrote, a bash box. Telling frames from
24
+ * text by their shape is what this is, so a bare rounded box a model drew in an
25
+ * unframed message reads as a frame.
26
+ */
27
+
28
+ import { sliceByColumn, stripTerminalSequences, TuiAltScreen, visibleWidth } from "@earendil-works/pi-tui";
29
+ import { liveSlot, tidyEnabled } from "./live.ts";
30
+ import { USER_RAIL_GLYPH } from "./messages/user-message.ts";
31
+ import { CHIP_LABELS, isOpenRule, isRuleOnly, ruleCells } from "./turns/code-blocks.ts";
32
+
33
+ /** `null` drops the row. `leftTrim` is the columns taken off its left edge. */
34
+ export type CleanedRow = { text: string; leftTrim: number } | null;
35
+
36
+ const USER_RAIL_PREFIX = `${USER_RAIL_GLYPH} `;
37
+
38
+ const RULE_ROW_RE = /^─+$/;
39
+ /** A rail message's top rule, with who and when set into it; `├` where a run's rail meets it. */
40
+ const LABELED_RULE_RE = /^[─├]─*(?: [^─]+ ─+)+$/;
41
+ /** The rails and indent a frame hangs on. */
42
+ const HANG_RE = /^[│ ]*/;
43
+ /** `├` is a corner where a rail meets it; a table's `├─┼─┤` never ends in a round one. */
44
+ const FRAME_OPEN_RE = /^[╭├]─*(?: [^─]+ ─+)*╮$/;
45
+ const FRAME_CLOSE_RE = /^[╰├]─*╯$/;
46
+ /** The corner and the rules that pad the label out to the box's width. */
47
+ const EDGE_LEAD_RE = /^[╭├][─ ]*/;
48
+ /** The closing fill and corner of a labelled edge. */
49
+ const EDGE_TAIL_RE = /[─ ]*╮$/;
50
+ /** The glyphs a box puts in front of its title: kind icon, file icon. */
51
+ const EDGE_ICONS_RE = /^(?:[\uE000-\uF8FF\u{F0000}-\u{FFFFF}]+\s*)+/u;
52
+
53
+ /**
54
+ * A frame's top edge is not always chrome: the label it carries may be what the reader
55
+ * selected. A tool box puts the file's path there, and dragging along that border used
56
+ * to copy nothing at all. A reply's label says who answered and when, which is drawing,
57
+ * so it keeps coming off. Shape is all this has to go on: a label that ends in a word
58
+ * naming something on disk (a `/` inside it, or a leading `~`) is content.
59
+ */
60
+ const EDGE_PATH_RE = /(^|\s)(~[^\s]*|[^\s]*\/[^\s]*)$/;
61
+
62
+ function namesPath(label: string): boolean {
63
+ return EDGE_PATH_RE.test(label.trim());
64
+ }
65
+
66
+ /**
67
+ * The label a frame's top edge carries, and where it starts. The corner, the rules
68
+ * padding the row, the icons in front of a title and the copy chip after its path are
69
+ * drawing; the label itself stays byte-identical.
70
+ */
71
+ function edgeLabel(edge: string): { text: string; offset: number } | undefined {
72
+ const lead = EDGE_LEAD_RE.exec(edge)![0];
73
+ const rest = edge.slice(lead.length);
74
+ const icons = EDGE_ICONS_RE.exec(rest)?.[0] ?? "";
75
+ let text = rest.slice(icons.length).replace(EDGE_TAIL_RE, "");
76
+ for (const chip of CHIP_LABELS) {
77
+ if (!text.endsWith(` ${chip}`)) continue;
78
+ text = text.slice(0, -chip.length - 1);
79
+ break;
80
+ }
81
+ if (!namesPath(text)) return undefined;
82
+ return { text, offset: visibleWidth(lead) + visibleWidth(icons) };
83
+ }
84
+
85
+ function isRailRow(text: string): boolean {
86
+ return text.startsWith(USER_RAIL_PREFIX) || text.trimEnd() === USER_RAIL_GLYPH;
87
+ }
88
+
89
+ /**
90
+ * Classify the whole buffer, then clean the rows in `[startRow, endRow]`. A drag that
91
+ * starts inside a box never contains the box's edges, so what a row is cannot depend
92
+ * on the selection. `changed` is false when no selected row carried any chrome.
93
+ */
94
+ export function cleanTranscriptRows(
95
+ lines: readonly string[],
96
+ startRow: number,
97
+ endRow: number,
98
+ ): { rows: CleanedRow[]; changed: boolean } {
99
+ const plain = lines.map((line) => stripTerminalSequences(line));
100
+ const dropped = new Set<number>();
101
+ const frameRows = new Map<number, { left: number; inset: number }>();
102
+ /** A top edge that carries a label: the label is content, the rest of the row is not. */
103
+ const labelRows = new Map<number, CleanedRow>();
104
+ const railRows = new Map<number, string>();
105
+ const flatRows = new Set<number>();
106
+
107
+ // Rounded frames. Edges pair by width and column; a frame inside another pairs first.
108
+ const open: Array<{ row: number; width: number; left: number }> = [];
109
+ for (let row = 0; row < plain.length; row++) {
110
+ const raw = plain[row]!;
111
+ const left = HANG_RE.exec(raw)![0].length;
112
+ const edge = raw.slice(left).trimEnd();
113
+ if (FRAME_OPEN_RE.test(edge)) {
114
+ open.push({ row, width: visibleWidth(edge), left });
115
+ } else if (FRAME_CLOSE_RE.test(edge)) {
116
+ const top = open[open.length - 1];
117
+ if (!top || top.width !== visibleWidth(edge) || top.left !== left) continue;
118
+ open.pop();
119
+ dropped.add(row);
120
+ const label = edgeLabel(plain[top.row]!.slice(top.left).trimEnd());
121
+ if (label) labelRows.set(top.row, { text: label.text, leftTrim: top.left + label.offset });
122
+ else dropped.add(top.row);
123
+ const body = plain.slice(top.row + 1, row).map((text) => text.slice(left));
124
+ const inset = body.every((text) => text.startsWith(`${USER_RAIL_GLYPH} `)) ? 1 : 0;
125
+ for (let inner = top.row + 1; inner < row; inner++) frameRows.set(inner, { left, inset });
126
+ }
127
+ }
128
+
129
+ // A code block's rules are chrome, above and below. Only the opening one can say
130
+ // what it is (the chip carries it), so its closing rule is the next row that is
131
+ // nothing but rule glyphs. A model's own `---` rule stays where it is. This runs
132
+ // before the passes below so their pairing never mistakes a rule for a message edge.
133
+ for (let row = 0; row < plain.length; row++) {
134
+ if (!isOpenRule(plain[row]!)) continue;
135
+ dropped.add(row);
136
+ for (let below = row + 1; below < plain.length; below++) {
137
+ if (!isRuleOnly(plain[below]!)) continue;
138
+ dropped.add(below);
139
+ break;
140
+ }
141
+ }
142
+
143
+ // Rail user messages. A row without the rail breaks the pair, which is what keeps
144
+ // an assistant's own `---` rules, and the text between them, from being eaten.
145
+ let lastRule: { row: number; width: number } | undefined;
146
+ for (let row = 0; row < plain.length; row++) {
147
+ if (dropped.has(row)) continue;
148
+ const text = plain[row]!;
149
+ if (RULE_ROW_RE.test(text)) {
150
+ const width = visibleWidth(text);
151
+ if (lastRule && lastRule.width === width && row - lastRule.row >= 2) {
152
+ dropped.add(lastRule.row);
153
+ dropped.add(row);
154
+ for (let inner = lastRule.row + 1; inner < row; inner++) {
155
+ const body = plain[inner]!;
156
+ railRows.set(inner, body.startsWith(USER_RAIL_PREFIX) && body.trim() !== USER_RAIL_GLYPH ? USER_RAIL_PREFIX : USER_RAIL_GLYPH);
157
+ }
158
+ }
159
+ lastRule = { row, width };
160
+ } else if (LABELED_RULE_RE.test(text)) {
161
+ // A label only ever opens a message, and it closes nothing above it.
162
+ lastRule = { row, width: visibleWidth(text) };
163
+ } else if (!isRailRow(text) && !ruleCells(text).startsWith("─")) {
164
+ // A code block's rule neither opens nor closes a message: it sits between
165
+ // two of them without breaking the pair.
166
+ lastRule = undefined;
167
+ }
168
+ }
169
+
170
+ // A message with only rules: a labeled top rule, an indented body, then the next plain
171
+ // rule of its width. The label is what tells it from a model's own rules, so a "rules"
172
+ // user message with no icons, and so no label, keeps its rules.
173
+ for (let row = 0; row < plain.length; row++) {
174
+ if (dropped.has(row) || !LABELED_RULE_RE.test(plain[row]!)) continue;
175
+ const width = visibleWidth(plain[row]!);
176
+ for (let end = row + 1; end < plain.length && !dropped.has(end) && !LABELED_RULE_RE.test(plain[end]!); end++) {
177
+ if (!RULE_ROW_RE.test(plain[end]!) || visibleWidth(plain[end]!) !== width) continue;
178
+ const body = Array.from({ length: end - row - 1 }, (_, index) => row + 1 + index);
179
+ // A code block's rules are chrome too, and they are not what ends this body.
180
+ if (body.length === 0 || !body.every((inner) => plain[inner]!.trim() === "" || plain[inner]!.startsWith(" ") || ruleCells(plain[inner]!).startsWith("─"))) break;
181
+ dropped.add(row);
182
+ dropped.add(end);
183
+ for (const inner of body) flatRows.add(inner);
184
+ break;
185
+ }
186
+ }
187
+
188
+ const rows: CleanedRow[] = [];
189
+ let changed = false;
190
+ for (let row = startRow; row <= endRow; row++) {
191
+ if (dropped.has(row)) {
192
+ rows.push(null);
193
+ changed = true;
194
+ continue;
195
+ }
196
+ const label = labelRows.get(row);
197
+ if (label) {
198
+ rows.push(label);
199
+ changed = true;
200
+ continue;
201
+ }
202
+ const text = plain[row] ?? "";
203
+ const frame = frameRows.get(row);
204
+ const framed = frame ? text.slice(frame.left).trimEnd() : "";
205
+ if (frame && framed.length >= 2 && framed.startsWith(USER_RAIL_GLYPH) && framed.endsWith(USER_RAIL_GLYPH)) {
206
+ rows.push({ text: framed.slice(1 + frame.inset, -1), leftTrim: frame.left + 1 + frame.inset });
207
+ changed = true;
208
+ continue;
209
+ }
210
+ const prefix = railRows.get(row);
211
+ if (prefix !== undefined && text.startsWith(prefix)) {
212
+ rows.push({ text: text.slice(prefix.length), leftTrim: visibleWidth(prefix) });
213
+ changed = true;
214
+ continue;
215
+ }
216
+ if (flatRows.has(row) && text.startsWith(" ")) {
217
+ rows.push({ text: text.slice(1), leftTrim: 1 });
218
+ changed = true;
219
+ continue;
220
+ }
221
+ rows.push({ text, leftTrim: 0 });
222
+ }
223
+
224
+ return { rows, changed };
225
+ }
226
+
227
+ interface Point {
228
+ row: number;
229
+ col: number;
230
+ boundary?: boolean;
231
+ scrollView?: unknown;
232
+ }
233
+ interface Bounds {
234
+ start: Point;
235
+ end: Point;
236
+ }
237
+ interface LayoutBox {
238
+ scrollView?: unknown;
239
+ scrollContentLines?: readonly string[];
240
+ children?: readonly LayoutBox[];
241
+ }
242
+ interface SelectionHost {
243
+ currentLayout?: { root?: LayoutBox };
244
+ getSelectionBounds(): Bounds | undefined;
245
+ getSelectionColumns(line: string, row: number, bounds: Bounds): { start: number; end: number };
246
+ hasOverlay(): boolean;
247
+ }
248
+
249
+ /** pi-tui keeps `getScrollViewBox` out of its entry point. */
250
+ function scrollLines(box: LayoutBox | undefined, scrollView: unknown): readonly string[] | undefined {
251
+ if (!box) return undefined;
252
+ if (box.scrollView === scrollView) return box.scrollContentLines;
253
+ for (const child of box.children ?? []) {
254
+ const found = scrollLines(child, scrollView);
255
+ if (found) return found;
256
+ }
257
+ return undefined;
258
+ }
259
+
260
+ /**
261
+ * The selection without its chrome, or undefined when pi's text is already right: no
262
+ * chrome selected, or the selection is outside the transcript's scroll view (the
263
+ * editor, a dialog). A selection of nothing but chrome is "": there is a selection,
264
+ * and pi's copy key must not fall back to copying the last reply.
265
+ */
266
+ function cleanSelection(host: SelectionHost): string | undefined {
267
+ const bounds = host.getSelectionBounds();
268
+ const scrollView = bounds?.start.scrollView;
269
+ if (!bounds || !scrollView || host.hasOverlay()) return undefined;
270
+ const lines = scrollLines(host.currentLayout?.root, scrollView);
271
+ if (!lines) return undefined;
272
+ const { rows, changed } = cleanTranscriptRows(lines, bounds.start.row, bounds.end.row);
273
+ if (!changed) return undefined;
274
+
275
+ // The drag's columns are screen columns; the rows just got shorter on the left.
276
+ const adjusted: Bounds = { start: bounds.start, end: bounds.end };
277
+ const out: string[] = [];
278
+ rows.forEach((entry, index) => {
279
+ if (entry === null) return;
280
+ const row = bounds.start.row + index;
281
+ if (row === bounds.start.row) adjusted.start = { ...bounds.start, col: Math.max(0, bounds.start.col - entry.leftTrim) };
282
+ if (row === bounds.end.row) adjusted.end = { ...bounds.end, col: Math.max(0, bounds.end.col - entry.leftTrim) };
283
+ const { start, end } = host.getSelectionColumns(entry.text, row, adjusted);
284
+ out.push(sliceByColumn(entry.text, start, Math.max(0, end - start), true).trimEnd());
285
+ });
286
+ return out.join("\n");
287
+ }
288
+
289
+ export const cleanCopy = liveSlot("clean-copy", true);
290
+
291
+ const CLEAN_COPY_HOOK = Symbol.for("pi-tidy:clean-copy");
292
+
293
+ /** Wrap pi's selection text once. False when this pi has no such method to wrap. */
294
+ export function installCleanCopy(): boolean {
295
+ const proto = (TuiAltScreen as unknown as { prototype?: Record<PropertyKey, any> })?.prototype;
296
+ if (!proto) return false;
297
+ if (proto[CLEAN_COPY_HOOK]) return true;
298
+ const original = proto.getActiveSelectionText;
299
+ if (typeof original !== "function") return false;
300
+ proto.getActiveSelectionText = function (this: SelectionHost): string | undefined {
301
+ const verbatim: string | undefined = original.call(this);
302
+ if (verbatim === undefined || !cleanCopy.get() || !tidyEnabled.get()) return verbatim;
303
+ try {
304
+ return cleanSelection(this) ?? verbatim;
305
+ } catch {
306
+ // This reads pi's private layout. Whatever it trips on, a copy with its
307
+ // chrome beats no copy at all.
308
+ return verbatim;
309
+ }
310
+ };
311
+ proto[CLEAN_COPY_HOOK] = true;
312
+ return true;
313
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * pi's own clipboard write, when this pi has one.
3
+ *
4
+ * Imported as a namespace on purpose: a named import of an export an older pi
5
+ * lacks is a link error, and the whole extension would refuse to load over a
6
+ * copy chip. `copyToClipboard` itself falls back native → platform command →
7
+ * OSC 52, so nothing here touches escape sequences.
8
+ */
9
+
10
+ import * as pi from "@earendil-works/pi-coding-agent";
11
+
12
+ type Copy = (text: string) => Promise<void>;
13
+
14
+ const piCopy = (pi as { copyToClipboard?: Copy }).copyToClipboard;
15
+ let writer = piCopy;
16
+
17
+ /** Whether a chip that copies anything would do anything on this pi. */
18
+ export function canCopyText(): boolean {
19
+ return typeof writer === "function";
20
+ }
21
+
22
+ export async function copyText(text: string): Promise<void> {
23
+ await writer?.(text);
24
+ }
25
+
26
+ /** Tests swap the writer: a click must not touch the real clipboard. */
27
+ export function setClipboardWriter(next: Copy | undefined): void {
28
+ writer = next ?? piCopy;
29
+ }