@levistudio/redline 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.
- package/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/ROADMAP.md +39 -0
- package/SECURITY.md +33 -0
- package/bin/redline.cjs +61 -0
- package/package.json +61 -0
- package/scripts/install-skill.sh +78 -0
- package/skills/redline-review/SKILL.md +102 -0
- package/src/agent.ts +283 -0
- package/src/cli.ts +332 -0
- package/src/client/cards.ts +385 -0
- package/src/client/diff.ts +100 -0
- package/src/client/firstRunBanner.ts +26 -0
- package/src/client/lib.ts +299 -0
- package/src/client/main.ts +119 -0
- package/src/client/render.ts +413 -0
- package/src/client/selection.ts +253 -0
- package/src/client/sse.ts +179 -0
- package/src/client/state.ts +56 -0
- package/src/client/styles.css +994 -0
- package/src/contextBlock.ts +16 -0
- package/src/diff.ts +166 -0
- package/src/parseReply.ts +115 -0
- package/src/pickModel.ts +38 -0
- package/src/promptEnvelope.ts +58 -0
- package/src/render.ts +83 -0
- package/src/resolve.ts +290 -0
- package/src/server-page.ts +119 -0
- package/src/server.ts +634 -0
- package/src/sidecar.ts +190 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Pure-ish helpers extracted from main.js for direct test coverage.
|
|
2
|
+
// These functions take their DOM dependencies as arguments — they don't read
|
|
3
|
+
// from globals — so the same code runs in the browser bundle and in happy-dom
|
|
4
|
+
// tests without a separate harness.
|
|
5
|
+
|
|
6
|
+
export type ClientComment = {
|
|
7
|
+
id: string;
|
|
8
|
+
quote: string;
|
|
9
|
+
context_before?: string;
|
|
10
|
+
context_after?: string;
|
|
11
|
+
resolved: boolean;
|
|
12
|
+
thread: ThreadEntry[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ThreadEntry = {
|
|
16
|
+
role?: "human" | "agent";
|
|
17
|
+
name?: string;
|
|
18
|
+
message: string;
|
|
19
|
+
requires_revision?: boolean;
|
|
20
|
+
revision_reason?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function escapeHtml(s: string): string {
|
|
24
|
+
return s
|
|
25
|
+
.replace(/&/g, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, """);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Latest agent verdict on a comment thread. Mirrors latestVerdict() in sidecar.ts.
|
|
32
|
+
export function latestVerdict(comment: ClientComment): "revise" | "accept" | null {
|
|
33
|
+
const t = comment.thread || [];
|
|
34
|
+
for (let i = t.length - 1; i >= 0; i--) {
|
|
35
|
+
const e = t[i]!;
|
|
36
|
+
if (e.role !== "agent") continue;
|
|
37
|
+
if (typeof e.requires_revision !== "boolean") continue;
|
|
38
|
+
return e.requires_revision ? "revise" : "accept";
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function nearestCell(node: Node): HTMLElement | null {
|
|
44
|
+
const el = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
|
|
45
|
+
return el && (el as Element).closest ? ((el as Element).closest("td, th") as HTMLElement | null) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Clamp a Range so both endpoints land inside the same cell. Returns a new
|
|
49
|
+
// Range, or null if no meaningful clamp is possible. Called when the user
|
|
50
|
+
// dragged across a cell boundary by accident.
|
|
51
|
+
export function clampRangeToCell(
|
|
52
|
+
range: Range,
|
|
53
|
+
startCell: HTMLElement | null,
|
|
54
|
+
endCell: HTMLElement | null,
|
|
55
|
+
doc: Document = document,
|
|
56
|
+
): Range | null {
|
|
57
|
+
const cell = startCell || endCell;
|
|
58
|
+
if (!cell) return null;
|
|
59
|
+
const newRange = doc.createRange();
|
|
60
|
+
try {
|
|
61
|
+
if (startCell === cell) {
|
|
62
|
+
newRange.setStart(range.startContainer, range.startOffset);
|
|
63
|
+
newRange.setEnd(cell, cell.childNodes.length);
|
|
64
|
+
} else {
|
|
65
|
+
newRange.setStart(cell, 0);
|
|
66
|
+
newRange.setEnd(range.endContainer, range.endOffset);
|
|
67
|
+
}
|
|
68
|
+
return newRange;
|
|
69
|
+
} catch {
|
|
70
|
+
try {
|
|
71
|
+
newRange.selectNodeContents(cell);
|
|
72
|
+
return newRange;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type Captured = {
|
|
80
|
+
quote: string;
|
|
81
|
+
context_before: string;
|
|
82
|
+
context_after: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Walk the prose container's text nodes, locate the selection's start, and
|
|
86
|
+
// return the quote with surrounding context. Returns null when the selection
|
|
87
|
+
// can't be relocated against flat text (e.g. crossed an <img>).
|
|
88
|
+
export function captureSelection(prose: Element, sel: Selection, text: string): Captured | null {
|
|
89
|
+
const range = sel.getRangeAt(0);
|
|
90
|
+
|
|
91
|
+
const walker = (prose.ownerDocument || document).createTreeWalker(prose, NodeFilter.SHOW_TEXT);
|
|
92
|
+
const segments: { node: Text; start: number }[] = [];
|
|
93
|
+
let flat = "";
|
|
94
|
+
let node: Node | null;
|
|
95
|
+
while ((node = walker.nextNode())) {
|
|
96
|
+
const tn = node as Text;
|
|
97
|
+
segments.push({ node: tn, start: flat.length });
|
|
98
|
+
flat += tn.nodeValue ?? "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let quoteStart = -1;
|
|
102
|
+
for (const seg of segments) {
|
|
103
|
+
if (seg.node === range.startContainer) {
|
|
104
|
+
quoteStart = seg.start + range.startOffset;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (quoteStart === -1) {
|
|
110
|
+
return { quote: text, context_before: "", context_after: "" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const slice = flat.slice(quoteStart, quoteStart + text.length);
|
|
114
|
+
if (slice !== text) {
|
|
115
|
+
// Trimming in the caller, or block-boundary newlines that sel.toString()
|
|
116
|
+
// inserts but our flat-text walker doesn't, can shift the true start a
|
|
117
|
+
// few chars away from range.startOffset. Search a small window around
|
|
118
|
+
// quoteStart for the text before giving up.
|
|
119
|
+
const windowStart = Math.max(0, quoteStart - 64);
|
|
120
|
+
const windowEnd = Math.min(flat.length, quoteStart + text.length + 64);
|
|
121
|
+
const found = flat.indexOf(text, windowStart);
|
|
122
|
+
if (found === -1 || found >= windowEnd) return null;
|
|
123
|
+
quoteStart = found;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
quote: text,
|
|
128
|
+
context_before: flat.slice(Math.max(0, quoteStart - 32), quoteStart),
|
|
129
|
+
context_after: flat.slice(quoteStart + text.length, quoteStart + text.length + 32),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Wrap occurrences of `text` inside `container` with <mark> elements. Uses
|
|
134
|
+
// `contextBefore` to disambiguate when a quote appears multiple times.
|
|
135
|
+
// Image quotes (`[image: alt]`) wrap the matching <img> instead.
|
|
136
|
+
// Returns the marks created (caller can attach event listeners).
|
|
137
|
+
export function highlightText(
|
|
138
|
+
container: Element,
|
|
139
|
+
text: string,
|
|
140
|
+
id: string,
|
|
141
|
+
resolved: boolean,
|
|
142
|
+
contextBefore: string,
|
|
143
|
+
): HTMLElement[] {
|
|
144
|
+
const doc = container.ownerDocument || document;
|
|
145
|
+
const marks: HTMLElement[] = [];
|
|
146
|
+
|
|
147
|
+
const imgMatch = text.match(/^\[image:\s*(.*)\]$/);
|
|
148
|
+
if (imgMatch) {
|
|
149
|
+
const alt = imgMatch[1];
|
|
150
|
+
const imgs = container.querySelectorAll("img");
|
|
151
|
+
for (const img of imgs) {
|
|
152
|
+
if ((img.alt || "") === alt) {
|
|
153
|
+
const mark = doc.createElement("mark");
|
|
154
|
+
mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
|
|
155
|
+
mark.dataset.commentId = id;
|
|
156
|
+
img.parentNode!.insertBefore(mark, img);
|
|
157
|
+
mark.appendChild(img);
|
|
158
|
+
marks.push(mark);
|
|
159
|
+
return marks;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return marks;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
(container as HTMLElement).normalize();
|
|
166
|
+
|
|
167
|
+
const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
168
|
+
const segments: { node: Text; start: number }[] = [];
|
|
169
|
+
let flat = "";
|
|
170
|
+
let node: Node | null;
|
|
171
|
+
while ((node = walker.nextNode())) {
|
|
172
|
+
const tn = node as Text;
|
|
173
|
+
segments.push({ node: tn, start: flat.length });
|
|
174
|
+
flat += tn.nodeValue ?? "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let quoteStart = -1;
|
|
178
|
+
if (contextBefore) {
|
|
179
|
+
const ctxIdx = flat.indexOf(contextBefore + text);
|
|
180
|
+
if (ctxIdx !== -1) quoteStart = ctxIdx + contextBefore.length;
|
|
181
|
+
}
|
|
182
|
+
if (quoteStart === -1) quoteStart = flat.indexOf(text);
|
|
183
|
+
if (quoteStart === -1) return marks;
|
|
184
|
+
|
|
185
|
+
const quoteEnd = quoteStart + text.length;
|
|
186
|
+
|
|
187
|
+
const toWrap: { node: Text; localStart: number; localEnd: number }[] = [];
|
|
188
|
+
for (const seg of segments) {
|
|
189
|
+
const segEnd = seg.start + (seg.node.nodeValue?.length ?? 0);
|
|
190
|
+
if (segEnd <= quoteStart || seg.start >= quoteEnd) continue;
|
|
191
|
+
toWrap.push({
|
|
192
|
+
node: seg.node,
|
|
193
|
+
localStart: Math.max(0, quoteStart - seg.start),
|
|
194
|
+
localEnd: Math.min(seg.node.nodeValue?.length ?? 0, quoteEnd - seg.start),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const { node: tn, localStart, localEnd } of toWrap) {
|
|
199
|
+
const mark = doc.createElement("mark");
|
|
200
|
+
mark.className = "rl-highlight" + (resolved ? " resolved" : "");
|
|
201
|
+
mark.dataset.commentId = id;
|
|
202
|
+
const mid = tn.splitText(localStart);
|
|
203
|
+
mid.splitText(localEnd - localStart);
|
|
204
|
+
mid.parentNode!.insertBefore(mark, mid);
|
|
205
|
+
mark.appendChild(mid);
|
|
206
|
+
marks.push(mark);
|
|
207
|
+
}
|
|
208
|
+
return marks;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type NavState = {
|
|
212
|
+
visible: boolean;
|
|
213
|
+
countText: string;
|
|
214
|
+
navIdx: number;
|
|
215
|
+
showPrev: boolean;
|
|
216
|
+
prevDisabled: boolean;
|
|
217
|
+
nextLabel: string;
|
|
218
|
+
nextDisabled: boolean;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// Pure state computation for the prev/next nav above the prose. Given the
|
|
222
|
+
// comments, the active card's id (if any), and the previous navIdx, return
|
|
223
|
+
// what the buttons should display. Mirrors the body of updateNav() in main.js.
|
|
224
|
+
export function computeNavState(
|
|
225
|
+
comments: ClientComment[],
|
|
226
|
+
activeId: string | null,
|
|
227
|
+
prevNavIdx: number,
|
|
228
|
+
): NavState {
|
|
229
|
+
const open = comments.filter((c) => !c.resolved);
|
|
230
|
+
if (open.length === 0) {
|
|
231
|
+
return {
|
|
232
|
+
visible: false,
|
|
233
|
+
countText: "",
|
|
234
|
+
navIdx: prevNavIdx,
|
|
235
|
+
showPrev: false,
|
|
236
|
+
prevDisabled: true,
|
|
237
|
+
nextLabel: "",
|
|
238
|
+
nextDisabled: true,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const matchIdx = activeId ? open.findIndex((c) => c.id === activeId) : -1;
|
|
242
|
+
const navIdx = matchIdx >= 0 ? matchIdx : Math.min(prevNavIdx, open.length - 1);
|
|
243
|
+
if (open.length === 1) {
|
|
244
|
+
return {
|
|
245
|
+
visible: true,
|
|
246
|
+
countText: "1 / 1",
|
|
247
|
+
navIdx,
|
|
248
|
+
showPrev: false,
|
|
249
|
+
prevDisabled: true,
|
|
250
|
+
nextLabel: "Jump to comment ↓",
|
|
251
|
+
nextDisabled: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
visible: true,
|
|
256
|
+
countText: navIdx + 1 + " / " + open.length,
|
|
257
|
+
navIdx,
|
|
258
|
+
showPrev: true,
|
|
259
|
+
prevDisabled: navIdx === 0,
|
|
260
|
+
nextLabel: "Next ↓",
|
|
261
|
+
nextDisabled: navIdx === open.length - 1,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Pin `window.scrollY` across a DOM mutation that may shift focus. Blurs the
|
|
266
|
+
// active element first (focus-loss scrolls fire a frame later), runs `fn`,
|
|
267
|
+
// then restores scroll synchronously and again across two rAF callbacks. The
|
|
268
|
+
// triple restore is not paranoia — focus-related scroll-into-view lands one
|
|
269
|
+
// frame after the call returns. `protectFocusSelector` opt-out is for the
|
|
270
|
+
// new-comment-form case where blurring would eat keystrokes.
|
|
271
|
+
export function preserveScroll(
|
|
272
|
+
fn: () => void,
|
|
273
|
+
opts: {
|
|
274
|
+
win?: Window;
|
|
275
|
+
doc?: Document;
|
|
276
|
+
protectFocusSelector?: string;
|
|
277
|
+
skip?: boolean;
|
|
278
|
+
} = {},
|
|
279
|
+
): void {
|
|
280
|
+
const win = opts.win ?? window;
|
|
281
|
+
const doc = opts.doc ?? win.document;
|
|
282
|
+
if (opts.skip) {
|
|
283
|
+
fn();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const top = win.scrollY;
|
|
287
|
+
const active = doc.activeElement as HTMLElement | null;
|
|
288
|
+
const protect =
|
|
289
|
+
opts.protectFocusSelector && active && active.closest && active.closest(opts.protectFocusSelector);
|
|
290
|
+
if (!protect && active && active !== doc.body && typeof active.blur === "function") active.blur();
|
|
291
|
+
fn();
|
|
292
|
+
doc.documentElement.scrollTop = top;
|
|
293
|
+
win.requestAnimationFrame(() => {
|
|
294
|
+
doc.documentElement.scrollTop = top;
|
|
295
|
+
win.requestAnimationFrame(() => {
|
|
296
|
+
doc.documentElement.scrollTop = top;
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { setCardCallbacks } from "./cards";
|
|
2
|
+
import {
|
|
3
|
+
renderComments,
|
|
4
|
+
applyHighlights,
|
|
5
|
+
applyRoundState,
|
|
6
|
+
focusComment,
|
|
7
|
+
clearFocus,
|
|
8
|
+
updateNav,
|
|
9
|
+
navigateTo,
|
|
10
|
+
positionCards,
|
|
11
|
+
resolveComment,
|
|
12
|
+
reopenComment,
|
|
13
|
+
submitReply,
|
|
14
|
+
toggleReplyForm,
|
|
15
|
+
} from "./render";
|
|
16
|
+
import { state } from "./state";
|
|
17
|
+
import { initSelectionHandlers } from "./selection";
|
|
18
|
+
import { initSSE } from "./sse";
|
|
19
|
+
import { showRevisionBanner, initDiffHandlers } from "./diff";
|
|
20
|
+
import { observeCardSizes } from "./cards";
|
|
21
|
+
|
|
22
|
+
// Wire card callbacks (breaks circular dependency between cards and render)
|
|
23
|
+
setCardCallbacks({
|
|
24
|
+
focusComment,
|
|
25
|
+
updateNav,
|
|
26
|
+
positionCards,
|
|
27
|
+
resolveComment,
|
|
28
|
+
reopenComment,
|
|
29
|
+
toggleReplyForm,
|
|
30
|
+
submitReply,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Click-to-clear-focus
|
|
34
|
+
document.addEventListener("click", (e) => {
|
|
35
|
+
const inCard = (e.target as HTMLElement).closest(".comment-card, .new-comment-form");
|
|
36
|
+
const inMark = (e.target as HTMLElement).closest("mark.rl-highlight");
|
|
37
|
+
const inNav = (e.target as HTMLElement).closest("#comment-nav");
|
|
38
|
+
if (!inCard && !inMark && !inNav) clearFocus();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Nav buttons
|
|
42
|
+
document.getElementById("nav-prev")!.addEventListener("click", () => {
|
|
43
|
+
const open = state.comments.filter((c) => !c.resolved);
|
|
44
|
+
if (state.navIdx > 0) {
|
|
45
|
+
state.navIdx--;
|
|
46
|
+
navigateTo(open[state.navIdx]!.id);
|
|
47
|
+
updateNav();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
document.getElementById("nav-next")!.addEventListener("click", () => {
|
|
51
|
+
const open = state.comments.filter((c) => !c.resolved);
|
|
52
|
+
if (open.length === 1) {
|
|
53
|
+
navigateTo(open[0]!.id);
|
|
54
|
+
} else if (state.navIdx < open.length - 1) {
|
|
55
|
+
state.navIdx++;
|
|
56
|
+
navigateTo(open[state.navIdx]!.id);
|
|
57
|
+
updateNav();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Broken images
|
|
62
|
+
function swapBrokenImg(img: HTMLImageElement): void {
|
|
63
|
+
const placeholder = document.createElement("div");
|
|
64
|
+
placeholder.className = "broken-img";
|
|
65
|
+
placeholder.textContent = "Image failed to load" + (img.alt ? ": " + img.alt : "");
|
|
66
|
+
img.replaceWith(placeholder);
|
|
67
|
+
}
|
|
68
|
+
document.querySelectorAll("#prose img").forEach((img) => {
|
|
69
|
+
const htmlImg = img as HTMLImageElement;
|
|
70
|
+
htmlImg.addEventListener("error", () => swapBrokenImg(htmlImg));
|
|
71
|
+
if (htmlImg.complete && htmlImg.naturalWidth === 0) swapBrokenImg(htmlImg);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Syntax highlighting
|
|
75
|
+
if (window.hljs) {
|
|
76
|
+
document.querySelectorAll('#prose pre code[class*="language-"]').forEach((el) => {
|
|
77
|
+
try {
|
|
78
|
+
window.hljs!.highlightElement(el as HTMLElement);
|
|
79
|
+
} catch {
|
|
80
|
+
/* unknown language */
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Initial render
|
|
86
|
+
renderComments();
|
|
87
|
+
applyHighlights();
|
|
88
|
+
positionCards();
|
|
89
|
+
updateNav();
|
|
90
|
+
applyRoundState();
|
|
91
|
+
|
|
92
|
+
if (sessionStorage.getItem("just-revised")) {
|
|
93
|
+
sessionStorage.removeItem("just-revised");
|
|
94
|
+
showRevisionBanner();
|
|
95
|
+
}
|
|
96
|
+
if (sessionStorage.getItem("rl-no-changes")) {
|
|
97
|
+
sessionStorage.removeItem("rl-no-changes");
|
|
98
|
+
const banner = document.getElementById("sidebar-status-banner");
|
|
99
|
+
if (banner) {
|
|
100
|
+
banner.classList.remove("revising");
|
|
101
|
+
banner.classList.remove("error");
|
|
102
|
+
banner.textContent = "No changes \u2014 the document is unchanged.";
|
|
103
|
+
banner.style.display = "block";
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
banner.style.display = "none";
|
|
106
|
+
}, 5000);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
window.addEventListener("scroll", positionCards, { passive: true });
|
|
111
|
+
window.addEventListener("resize", positionCards, { passive: true });
|
|
112
|
+
|
|
113
|
+
import { initFirstRunBanner } from "./firstRunBanner";
|
|
114
|
+
initFirstRunBanner(document, window);
|
|
115
|
+
|
|
116
|
+
// Init subsystems
|
|
117
|
+
initSelectionHandlers();
|
|
118
|
+
initDiffHandlers();
|
|
119
|
+
initSSE();
|