@cueloop/client 0.1.0-alpha.44 → 0.1.0-alpha.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.44",
3
+ "version": "0.1.0-alpha.45",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -26,10 +26,10 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.44",
30
- "@cueloop/extension-api": "0.1.0-alpha.44",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.44",
32
- "@cueloop/schema": "0.1.0-alpha.44",
29
+ "@cueloop/daemon": "0.1.0-alpha.45",
30
+ "@cueloop/extension-api": "0.1.0-alpha.45",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.45",
32
+ "@cueloop/schema": "0.1.0-alpha.45",
33
33
  "@opentui/core": "^0.5.2",
34
34
  "@opentui/keymap": "0.5.2",
35
35
  "@opentui/react": "^0.5.2",
@@ -87,6 +87,20 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
87
87
  return backgrounds;
88
88
  }
89
89
 
90
+ function foregroundsOf(setup: Setup, needle: string): string[] {
91
+ const foregrounds: string[] = [];
92
+ for (const line of setup.captureSpans().lines) {
93
+ for (const span of line.spans) {
94
+ if (!span.text.includes(needle)) continue;
95
+ const [red, green, blue] = span.fg.toInts();
96
+ foregrounds.push(
97
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
98
+ );
99
+ }
100
+ }
101
+ return foregrounds;
102
+ }
103
+
90
104
  /** Move the cursor to "The daemon persists sessions to disk atomically." */
91
105
  async function toContextParagraph(setup: Setup): Promise<void> {
92
106
  await press(setup, "j");
@@ -615,9 +629,9 @@ describe("edit-exit reconciliation", () => {
615
629
 
616
630
  // Act
617
631
  // deselect the card so e reaches the editor hand-off, then edit; deselection
618
- // shows as the card's selected fill (elevated) clearing to a transparent card
632
+ // shows as the card's quote line dropping the selection tone (blue)
619
633
  await press(setup, "escape");
620
- await waitForState(setup, () => !backgroundsOf(setup, "Anchor me").includes(DARK.elevated));
634
+ await waitForState(setup, () => !foregroundsOf(setup, "The daemon").includes(DARK.blue));
621
635
  await press(setup, "e");
622
636
 
623
637
  // Assert
@@ -150,10 +150,10 @@ describe("observer navigation still works", () => {
150
150
  await press(setup, "n");
151
151
  await setup.renderOnce();
152
152
 
153
- // Assert - focus shows as the card's bright kind-colored border (transparent
154
- // fill); a selected card is the only accent-colored border in the frame
153
+ // Assert - focus shows as the card's blue border (transparent fill); a
154
+ // comment card is the only blue-bordered box in the frame
155
155
  await waitForText(setup, "COMMENT · me");
156
- await waitForState(setup, () => hasBorderColor(setup, DARK.accent));
156
+ await waitForState(setup, () => hasBorderColor(setup, DARK.blue));
157
157
  });
158
158
  });
159
159
 
@@ -87,9 +87,8 @@ describe("rail submit confirm", () => {
87
87
  // Assert
88
88
  await waitForText(setup, "submit review");
89
89
  const frame = setup.captureCharFrame();
90
- // the card: title, honest counts, verdict selector, word-buttons
90
+ // the card: title, verdict selector, word-buttons
91
91
  expect(frame).toContain("submit review");
92
- expect(frame).toContain("0 annotations · 0 blocking");
93
92
  expect(frame).toContain("[Approve]"); // nothing pending: approve default
94
93
  expect(frame).toContain(" Submit ");
95
94
  expect(frame).toContain(" Cancel ");
@@ -136,7 +135,7 @@ describe("rail submit confirm", () => {
136
135
  // Assert
137
136
  // a bare ESC settles after the parser's escape-sequence window
138
137
  const frame = await waitForTextGone(setup, "[Approve]");
139
- expect(frame).not.toContain("0 annotations");
138
+ expect(frame).not.toContain("submit review");
140
139
  expect(frame).toContain("Submit review");
141
140
  });
142
141
 
@@ -149,10 +148,9 @@ describe("rail submit confirm", () => {
149
148
  await press(setup, "enter");
150
149
 
151
150
  // Assert
152
- await waitForText(setup, "1 annotations · 0 blocking");
151
+ await waitForText(setup, "[Changes]"); // pending items: request changes default
153
152
  const frame = setup.captureCharFrame();
154
- expect(frame).toContain("1 annotations · 0 blocking");
155
- expect(frame).toContain("[Changes]"); // pending items: request changes default
153
+ expect(frame).toContain("submit review");
156
154
 
157
155
  // Act
158
156
  await setup.mockInput.typeText("Tighten the steps.");
@@ -183,7 +181,7 @@ describe("rail submit confirm", () => {
183
181
  await press(setup, "enter");
184
182
 
185
183
  // Assert
186
- await waitForText(setup, "12 annotations · 0 blocking");
184
+ await waitForText(setup, "[Changes]");
187
185
  // the card is pinned outside the scrollbox; the stack stays scrolled off the top
188
186
  expect(setup.captureCharFrame()).not.toContain("note 01");
189
187
 
@@ -196,7 +194,7 @@ describe("rail submit confirm", () => {
196
194
  // Assert
197
195
  await waitForText(setup, "note 12");
198
196
  const scrolledWithCard = setup.captureCharFrame();
199
- expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
197
+ expect(scrolledWithCard).toContain("submit review");
200
198
  expect(scrolledWithCard).not.toContain("note 01");
201
199
  });
202
200
 
package/src/App.tsx CHANGED
@@ -58,7 +58,7 @@ import { CLIENT_VERSION } from "./version";
58
58
  import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
59
59
  import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
60
60
  import { DiffSheet } from "./components/DiffSheet";
61
- import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
61
+ import { type ReviewRailHandle } from "./components/ReviewRail";
62
62
  import type { AgentTerminalHandle } from "./components/agent-launcher";
63
63
  import { ReviewPanel } from "./components/ReviewPanel";
64
64
  import {
@@ -853,30 +853,36 @@ export function App({
853
853
  }
854
854
  : null;
855
855
 
856
+ // a mouse drag leaves a native selection: turn it into a word span so the
857
+ // marker popover opens at the dragged range, mirroring the `v` grammar.
858
+ // Returns whether a native selection existed (the release ended a drag).
859
+ const activateSpanFromSelection = (preferredIndex?: number): boolean => {
860
+ if (!renderer?.hasSelection) return false;
861
+ // the release block re-anchors the span, so each drag replaces the last
862
+ const selection = planSheetRef.current?.readSelection(preferredIndex);
863
+ const block = selection ? display[selection.displayIndex] : undefined;
864
+ const span =
865
+ selection && block
866
+ ? spanFromRange(selection.displayIndex, displayText(block), selection.start, selection.end)
867
+ : null;
868
+ if (span) setMode({ type: "span", span });
869
+ return true;
870
+ };
871
+
856
872
  const onLineActivate = (displayIndex: number): void => {
857
- // a mouse drag leaves a native selection: turn it into a word span so the
858
- // marker popover opens at the dragged range, mirroring the `v` grammar
859
- if (renderer?.hasSelection) {
860
- // the release block re-anchors the span, so each drag replaces the last
861
- const selection = planSheetRef.current?.readSelection(displayIndex);
862
- const block = selection ? display[selection.displayIndex] : undefined;
863
- const span =
864
- selection && block
865
- ? spanFromRange(
866
- selection.displayIndex,
867
- displayText(block),
868
- selection.start,
869
- selection.end,
870
- )
871
- : null;
872
- if (span) setMode({ type: "span", span });
873
- return;
874
- }
873
+ if (activateSpanFromSelection(displayIndex)) return;
875
874
  setCursor(displayIndex);
876
875
  const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
877
876
  if (annotationId) selectCardFromDocument(annotationId);
878
877
  };
879
878
 
879
+ // a drag released outside any block's text (the gutter, past a line end, a
880
+ // gap between blocks) never reaches a block handler; the sheet-level release
881
+ // still turns the finished drag into a span
882
+ const onSelectionRelease = (): void => {
883
+ activateSpanFromSelection();
884
+ };
885
+
880
886
  const onEditRequest = (): void => {
881
887
  // A share viewer/observer has no Edit affordance (the button is hidden), so
882
888
  // this is owner-only; stay silent rather than nag if it is ever reached.
@@ -916,8 +922,6 @@ export function App({
916
922
  ? {
917
923
  verdict: mode.verdict,
918
924
  summary: mode.summary,
919
- annotationCount: reviewerAnnotations(activeSession).length,
920
- blockingCount: reviewerAnnotations(activeSession).filter(annotationBlocking).length,
921
925
  // walk coverage keeps partial passes honest at the verdict
922
926
  viewedSummary:
923
927
  isDiff && activeSession.viewedPaths !== undefined
@@ -1044,6 +1048,7 @@ export function App({
1044
1048
  popover={popoverState}
1045
1049
  editOrphanCount={editOrphanCount}
1046
1050
  onLineActivate={onLineActivate}
1051
+ onSelectionRelease={onSelectionRelease}
1047
1052
  />
1048
1053
  )}
1049
1054
  <ReviewPanel
@@ -242,7 +242,8 @@ describe("the guided walk", () => {
242
242
  await press(setup, "escape");
243
243
  await waitForTextGone(setup, "agent note");
244
244
  await press(setup, "enter");
245
- await waitForText(setup, "0 annotations");
245
+ // nothing pending despite the note, so the confirm card defaults to approve
246
+ await waitForText(setup, "[Approve]");
246
247
  await press(setup, "enter");
247
248
 
248
249
  // Assert
@@ -0,0 +1,100 @@
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
+
3
+ exports[`view-plan regression harness (M0) blockRuns output is stable across the representative blocks 1`] = `
4
+ [
5
+ {
6
+ "runs": [
7
+ {
8
+ "role": "plain",
9
+ "start": 0,
10
+ "text": "Heading with ",
11
+ },
12
+ {
13
+ "role": "strong",
14
+ "start": 15,
15
+ "text": "bold",
16
+ },
17
+ ],
18
+ "text": "Heading with **bold**",
19
+ "type": "same",
20
+ },
21
+ {
22
+ "runs": [
23
+ {
24
+ "role": "plain",
25
+ "start": 0,
26
+ "text": "A paragraph with ",
27
+ },
28
+ {
29
+ "role": "strong",
30
+ "start": 19,
31
+ "text": "strong",
32
+ },
33
+ {
34
+ "role": "plain",
35
+ "start": 27,
36
+ "text": ", ",
37
+ },
38
+ {
39
+ "role": "em",
40
+ "start": 30,
41
+ "text": "em",
42
+ },
43
+ {
44
+ "role": "plain",
45
+ "start": 33,
46
+ "text": ", ",
47
+ },
48
+ {
49
+ "role": "code",
50
+ "start": 36,
51
+ "text": "code",
52
+ },
53
+ {
54
+ "role": "plain",
55
+ "start": 41,
56
+ "text": ", ",
57
+ },
58
+ {
59
+ "role": "strike",
60
+ "start": 45,
61
+ "text": "strike",
62
+ },
63
+ {
64
+ "role": "plain",
65
+ "start": 53,
66
+ "text": " and a ",
67
+ },
68
+ {
69
+ "href": "https://example.com",
70
+ "role": "link",
71
+ "start": 61,
72
+ "text": "label",
73
+ },
74
+ {
75
+ "role": "plain",
76
+ "start": 88,
77
+ "text": ".",
78
+ },
79
+ ],
80
+ "text": "A paragraph with **strong**, *em*, \`code\`, ~~strike~~ and a [label](https://example.com).",
81
+ "type": "same",
82
+ },
83
+ {
84
+ "runs": [
85
+ {
86
+ "role": "plain",
87
+ "start": 0,
88
+ "text": "a list item with ",
89
+ },
90
+ {
91
+ "role": "em",
92
+ "start": 18,
93
+ "text": "emphasis",
94
+ },
95
+ ],
96
+ "text": "a list item with *emphasis*",
97
+ "type": "same",
98
+ },
99
+ ]
100
+ `;
@@ -15,7 +15,7 @@ export const CommentDraft: Story = {
15
15
  draft={{ text: "Which daemon?", ...draftCallbacks }}
16
16
  />
17
17
  ),
18
- expectedColors: [DARK.accent],
18
+ expectedColors: [DARK.blue],
19
19
  };
20
20
 
21
21
  export const SavedSelected: Story = {
@@ -27,15 +27,14 @@ export const SavedSelected: Story = {
27
27
  body: "Which daemon owns this?",
28
28
  isSelected: true,
29
29
  isOrphan: false,
30
- isBlocking: false,
31
30
  author: "me",
32
- tone: DARK.accent,
31
+ tone: DARK.blue,
33
32
  editing: null,
34
33
  onPress: () => {},
35
34
  }}
36
35
  />
37
36
  ),
38
- expectedColors: [DARK.accent],
37
+ expectedColors: [DARK.blue],
39
38
  };
40
39
 
41
40
  export const SavedCollaborator: Story = {
@@ -47,7 +46,6 @@ export const SavedCollaborator: Story = {
47
46
  body: "Which daemon owns this?",
48
47
  isSelected: false,
49
48
  isOrphan: false,
50
- isBlocking: false,
51
49
  author: "Priya",
52
50
  tone: DARK.blue,
53
51
  editing: null,
@@ -58,7 +56,7 @@ export const SavedCollaborator: Story = {
58
56
  expectedColors: [DARK.blue],
59
57
  };
60
58
 
61
- export const SavedOrphanBlocking: Story = {
59
+ export const SavedOrphan: Story = {
62
60
  render: () => (
63
61
  <AnnotationCard
64
62
  kind="comment"
@@ -67,15 +65,14 @@ export const SavedOrphanBlocking: Story = {
67
65
  body: "The passage is gone.",
68
66
  isSelected: false,
69
67
  isOrphan: true,
70
- isBlocking: true,
71
68
  author: "me",
72
- tone: DARK.accent,
69
+ tone: DARK.blue,
73
70
  editing: null,
74
71
  onPress: () => {},
75
72
  }}
76
73
  />
77
74
  ),
78
- expectedColors: [DARK.accent],
75
+ expectedColors: [DARK.blue],
79
76
  };
80
77
 
81
78
  export const SavedEditing: Story = {
@@ -87,9 +84,8 @@ export const SavedEditing: Story = {
87
84
  body: "old body",
88
85
  isSelected: true,
89
86
  isOrphan: false,
90
- isBlocking: false,
91
87
  author: "me",
92
- tone: DARK.accent,
88
+ tone: DARK.blue,
93
89
  editing: { text: "new body", ...draftCallbacks },
94
90
  onPress: () => {},
95
91
  }}
@@ -31,10 +31,9 @@ export interface AnnotationSaved {
31
31
  body: string;
32
32
  isSelected: boolean;
33
33
  isOrphan: boolean;
34
- isBlocking: boolean;
35
34
  /** Border-title author: a collaborator's display name, or "me" for the reviewer's own note. */
36
35
  author: string;
37
- /** Border + title color for this card (own = salmon, collaborator = blue). */
36
+ /** Border + title color for this card (comment cards read blue). */
38
37
  tone: string;
39
38
  /** Non-null while the card body is being rewritten in place. */
40
39
  editing: AnnotationDraft | null;
@@ -155,7 +154,8 @@ export function AnnotationCard({
155
154
  theme,
156
155
  }: AnnotationCardProps): React.ReactNode {
157
156
  const tokens = useComponentTheme(theme);
158
- const kindColor = tokens.accent;
157
+ // the composer wears the same blue as the saved comment card it becomes
158
+ const kindColor = tokens.blue;
159
159
  const activeDraft = draft ?? saved?.editing ?? null;
160
160
  // The composer's visible height, grown from the wrapped text. It is shared
161
161
  // with the card frame (contentRows) so the border never clips the textarea
@@ -186,10 +186,8 @@ export function AnnotationCard({
186
186
  );
187
187
  }
188
188
  const card = saved!;
189
- // border = "what + who" (+ any exceptional flag); body = the content only
190
- const flags = [card.isBlocking ? "BLOCKING" : null, card.isOrphan ? "ORPHANED" : null].filter(
191
- (flag): flag is string => flag !== null,
192
- );
189
+ // border = "what + who" (+ an orphaned flag when the anchor no longer resolves)
190
+ const flags = card.isOrphan ? ["ORPHANED"] : [];
193
191
  const title = ` ${[kind.toUpperCase(), card.author, ...flags].join(" · ")} `;
194
192
  // every saved card is bordered, so the border + side padding always eat this width
195
193
  const borderInset = 4;
@@ -205,16 +203,20 @@ export function AnnotationCard({
205
203
  border: true,
206
204
  borderStyle: FRAME_BORDER_STYLE,
207
205
  // the border (and its title) always wears the card's tone - salmon for
208
- // own notes, blue for a collaborator's; selection reads from a filled
209
- // background so the color stays put whether or not the card is active
206
+ // own notes, blue for a collaborator's; the card background stays
207
+ // transparent so it sits flat on the terminal theme. Selection reads
208
+ // from the quote line taking the card's tone (below) and from the
209
+ // matching text highlight in the document.
210
210
  borderColor: card.tone,
211
- backgroundColor: card.isSelected || card.editing ? tokens.elevated : "transparent",
211
+ backgroundColor: "transparent",
212
212
  paddingLeft: 1,
213
213
  paddingRight: 1,
214
214
  }}
215
215
  onMouseUp={card.onPress}
216
216
  >
217
- <text fg={tokens.textDim}>"{truncateToSingleLine(quote, 26 - borderInset)}"</text>
217
+ <text
218
+ fg={card.isSelected ? card.tone : tokens.textDim}
219
+ >{`"${truncateToSingleLine(quote, 26 - borderInset)}"`}</text>
218
220
  {card.editing ? (
219
221
  <DraftEditor
220
222
  draft={card.editing}
@@ -13,28 +13,14 @@ const callbacks = {
13
13
  };
14
14
 
15
15
  export const ApproveDefault: Story = {
16
- render: () => (
17
- <ConfirmCard
18
- verdict="approve"
19
- summary=""
20
- annotationCount={0}
21
- blockingCount={0}
22
- {...callbacks}
23
- />
24
- ),
16
+ render: () => <ConfirmCard verdict="approve" summary="" {...callbacks} />,
25
17
  expectedColors: [DARK.green, DARK.accent],
26
18
  size: { width: 40, height: 14 },
27
19
  };
28
20
 
29
21
  export const RequestChanges: Story = {
30
22
  render: () => (
31
- <ConfirmCard
32
- verdict="request_changes"
33
- summary="Tighten the steps."
34
- annotationCount={3}
35
- blockingCount={1}
36
- {...callbacks}
37
- />
23
+ <ConfirmCard verdict="request_changes" summary="Tighten the steps." {...callbacks} />
38
24
  ),
39
25
  expectedColors: [DARK.red],
40
26
  size: { width: 40, height: 14 },
@@ -42,14 +28,7 @@ export const RequestChanges: Story = {
42
28
 
43
29
  export const WithViewedSummary: Story = {
44
30
  render: () => (
45
- <ConfirmCard
46
- verdict="approve"
47
- summary=""
48
- annotationCount={1}
49
- blockingCount={0}
50
- viewedSummary="2/3 files viewed"
51
- {...callbacks}
52
- />
31
+ <ConfirmCard verdict="approve" summary="" viewedSummary="2/3 files viewed" {...callbacks} />
53
32
  ),
54
33
  expectedColors: [DARK.green, DARK.textDim],
55
34
  size: { width: 40, height: 14 },
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * The Submit button expanded into a bordered confirm card at the rail bottom:
3
- * honest counts, the verdict selector (arrow keys or click), the optional
4
- * summary, and plain word-buttons - key hints live in the status line only.
5
- * The bordered height derives from the content rows through Card, so layout
6
- * and render never drift.
3
+ * the verdict selector (arrow keys or click), the optional summary, and plain
4
+ * word-buttons - key hints live in the status line only. The bordered height
5
+ * derives from the content rows through Card, so layout and render never drift.
7
6
  */
8
7
 
9
8
  import React from "react";
@@ -26,8 +25,6 @@ export const VERDICT_LABEL: Record<VerdictKind, string> = {
26
25
  export interface ConfirmCardProps {
27
26
  verdict: VerdictKind;
28
27
  summary: string;
29
- annotationCount: number;
30
- blockingCount: number;
31
28
  /**
32
29
  * The guided walk's honest coverage line for diff sessions, e.g.
33
30
  * "2/3 files viewed". Undefined = no walk data, the row does not render.
@@ -40,8 +37,8 @@ export interface ConfirmCardProps {
40
37
  theme?: Theme;
41
38
  }
42
39
 
43
- /** Counts, spacer, 1-row verdict selector, spacer, summary input, spacer, buttons. */
44
- const CONFIRM_CONTENT_ROWS = 7;
40
+ /** 1-row verdict selector, spacer, summary input, spacer, buttons. */
41
+ const CONFIRM_CONTENT_ROWS = 5;
45
42
 
46
43
  export function verdictColor(verdict: VerdictKind, tokens: Theme): string {
47
44
  return verdict === "approve"
@@ -89,8 +86,6 @@ function VerdictSelector({
89
86
  export function ConfirmCard({
90
87
  verdict,
91
88
  summary,
92
- annotationCount,
93
- blockingCount,
94
89
  viewedSummary,
95
90
  onInput,
96
91
  onSelectVerdict,
@@ -102,17 +97,14 @@ export function ConfirmCard({
102
97
  return (
103
98
  <Card
104
99
  title=" submit review "
105
- contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 1 : 0)}
100
+ contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 2 : 0)}
106
101
  borderColor={tokens.text}
107
102
  backgroundColor="transparent"
108
103
  marginRight={1}
109
104
  theme={theme}
110
105
  >
111
- <text
112
- fg={tokens.textDim}
113
- >{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
114
106
  {viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
115
- <box style={{ height: 1 }} />
107
+ {viewedSummary !== undefined ? <box style={{ height: 1 }} /> : null}
116
108
  <VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
117
109
  <box style={{ height: 1 }} />
118
110
  <input
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import React from "react";
11
+ import type { MouseEvent } from "@opentui/core";
11
12
  import type { Theme } from "../theme";
12
13
  import type { QuickAction } from "../config";
13
14
  import { useComponentTheme } from "./theme-context";
@@ -56,7 +57,12 @@ export function MarkerPopover({
56
57
  };
57
58
  const open = view === "actions";
58
59
  return (
59
- <box style={{ flexDirection: "column", alignItems: "flex-start" }}>
60
+ <box
61
+ style={{ flexDirection: "column", alignItems: "flex-start" }}
62
+ // a release on the popover is the popover's own click; it must never
63
+ // bubble to the sheet's selection-release fallback and re-enter span mode
64
+ onMouseUp={(event: MouseEvent) => event.stopPropagation()}
65
+ >
60
66
  <box style={{ ...cardStyle, height: cardHeight(1) }}>
61
67
  <box style={{ flexDirection: "row" }}>
62
68
  <text fg={tokens.accent} onMouseUp={onComment}>
@@ -16,7 +16,9 @@ import {
16
16
  blockRuns,
17
17
  displayText,
18
18
  overlayMarks,
19
- renderedOffsetFor,
19
+ renderedOffsetAtOrAfter,
20
+ renderedOffsetAtOrBefore,
21
+ safeLinkHref,
20
22
  workRangeForRendered,
21
23
  type DisplayBlock,
22
24
  type Mark,
@@ -33,6 +35,20 @@ import { FRAME_BORDER_STYLE } from "./primitives/frame";
33
35
  /** A cut block reads as removed: struck through and grayed, never red. */
34
36
  const CUT_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
35
37
 
38
+ /** Inline-emphasis attributes, reused per run so the render loop allocates none. */
39
+ const STRONG_ATTRIBUTES = createTextAttributes({ bold: true });
40
+ const EM_ATTRIBUTES = createTextAttributes({ italic: true });
41
+ const STRIKE_ATTRIBUTES = createTextAttributes({ strikethrough: true });
42
+ const LINK_ATTRIBUTES = createTextAttributes({ underline: true });
43
+ /** Block-level base attributes: headings read bold, blockquotes read italic. */
44
+ const HEADING_ATTRIBUTES = createTextAttributes({ bold: true });
45
+ const QUOTE_ATTRIBUTES = createTextAttributes({ italic: true });
46
+
47
+ /** The popover toolbar card is 3 rows tall and sits flush above the marked words. */
48
+ const POPOVER_ROWS_ABOVE = 3;
49
+ /** Widest toolbar row ("comment · cut · actions · [x]") plus border and padding. */
50
+ const POPOVER_TOOLBAR_COLUMNS = 33;
51
+
36
52
  export interface PlanSelection {
37
53
  displayIndex: number;
38
54
  start: number;
@@ -73,6 +89,13 @@ export interface PlanSheetProps {
73
89
  popover: PlanPopoverState | null;
74
90
  editOrphanCount: number;
75
91
  onLineActivate: (displayIndex: number) => void;
92
+ /**
93
+ * Fires on any mouse release inside the sheet. A drag released outside a
94
+ * block's text (the gutter, past a line end, a gap between blocks) never
95
+ * reaches a block's own handler, so this fallback lets the app still turn
96
+ * the finished drag's native selection into a span.
97
+ */
98
+ onSelectionRelease?: () => void;
76
99
  theme?: Theme;
77
100
  }
78
101
 
@@ -108,6 +131,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
108
131
  popover,
109
132
  editOrphanCount,
110
133
  onLineActivate,
134
+ onSelectionRelease,
111
135
  theme,
112
136
  }: PlanSheetProps,
113
137
  handleRef,
@@ -146,9 +170,11 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
146
170
  if (!renderer) return;
147
171
  const blockRef = blockRefs.current.get(span.displayIndex);
148
172
  if (!blockRef) return;
149
- const renderedStart = renderedOffsetFor(blockRef.runs, span.start);
150
- const renderedEnd = renderedOffsetFor(blockRef.runs, span.end - 1);
151
- if (renderedStart === null || renderedEnd === null) return;
173
+ // a span over an emphasized word starts/ends on concealed markers with
174
+ // no rendered cell; snap to the visible characters inside them
175
+ const renderedStart = renderedOffsetAtOrAfter(blockRef.runs, span.start);
176
+ const renderedEnd = renderedOffsetAtOrBefore(blockRef.runs, span.end - 1);
177
+ if (renderedStart === null || renderedEnd === null || renderedEnd < renderedStart) return;
152
178
  const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
153
179
  const endPosition = positionOfRenderedOffset(blockRef.renderable, renderedEnd);
154
180
  renderer.startSelection(blockRef.renderable, startPosition.x, startPosition.y);
@@ -166,24 +192,43 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
166
192
  },
167
193
  }));
168
194
 
169
- // the popover floats above its block, anchored to the selection start; flip
170
- // below when the block sits too near the viewport top to fit above it
171
- const [popoverFlipBelow, setPopoverFlipBelow] = useState(false);
195
+ // the popover sits flush above the marked words, anchored to the
196
+ // selection start mapped through the wrap geometry (line + column, not the
197
+ // block's linear offset). It renders as the LAST child of the scrollbox in
198
+ // content coordinates: content coordinates are scroll-invariant so the card
199
+ // tracks its block, and the last sibling paints over every block it floats
200
+ // across. Flips below when the line sits too near the viewport top.
201
+ const [popoverTop, setPopoverTop] = useState(0);
172
202
  const [popoverLeft, setPopoverLeft] = useState(2);
203
+ // a terminal resize rewraps every block; re-run the anchor math against the
204
+ // new geometry instead of leaving the card at its pre-resize cell
205
+ const [resizeEpoch, setResizeEpoch] = useState(0);
206
+ useEffect(() => {
207
+ if (!renderer) return;
208
+ const onResize = (): void => setResizeEpoch((epoch) => epoch + 1);
209
+ renderer.on("resize", onResize);
210
+ return () => void renderer.off("resize", onResize);
211
+ }, [renderer]);
173
212
  useEffect(() => {
174
213
  if (!popover) return;
175
214
  const blockRef = blockRefs.current.get(popover.displayIndex);
176
215
  const scrollbox = scrollRef.current;
177
216
  if (!blockRef || !scrollbox) return;
178
- // only the toolbar (3 rows) + gap must fit above; the dropdown flows down
179
- setPopoverFlipBelow(blockRef.renderable.y - scrollbox.y < 4);
180
- // left-anchor to the start of the selection: gutter width + its column
181
- const startColumn =
217
+ const renderedStart =
182
218
  activeSpan && activeSpan.displayIndex === popover.displayIndex
183
- ? (renderedOffsetFor(blockRef.runs, activeSpan.start) ?? 0)
219
+ ? (renderedOffsetAtOrAfter(blockRef.runs, activeSpan.start) ?? 0)
184
220
  : 0;
185
- setPopoverLeft(2 + startColumn);
186
- }, [popover, activeSpan, display, cursor]);
221
+ const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
222
+ const content = scrollbox.content;
223
+ const anchorLeft = startPosition.x - content.x;
224
+ const anchorTop = startPosition.y - content.y;
225
+ // clamp so the toolbar card never runs past the content's right edge
226
+ const maxLeft = Math.max(0, content.width - POPOVER_TOOLBAR_COLUMNS);
227
+ setPopoverLeft(Math.min(anchorLeft, maxLeft));
228
+ // only the toolbar (3 rows) + gap must fit above; the dropdown flows down
229
+ const lineViewportRow = startPosition.y - scrollbox.y;
230
+ setPopoverTop(anchorTop + (lineViewportRow < POPOVER_ROWS_ABOVE ? 1 : -POPOVER_ROWS_ABOVE));
231
+ }, [popover, activeSpan, display, cursor, resizeEpoch]);
187
232
 
188
233
  const registerBlock = (
189
234
  displayIndex: number,
@@ -235,13 +280,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
235
280
  <box
236
281
  key={displayIndex}
237
282
  id={`plan-block-${displayIndex}`}
238
- // raise the block holding the popover so its overlay paints over the
239
- // later blocks it floats across (siblings paint in z-index order)
240
- style={{
241
- flexDirection: "row",
242
- marginTop: gap,
243
- zIndex: popover?.displayIndex === displayIndex ? 10 : undefined,
244
- }}
283
+ style={{ flexDirection: "row", marginTop: gap }}
245
284
  >
246
285
  <text selectable={false}>
247
286
  <span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
@@ -258,31 +297,22 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
258
297
  }
259
298
  onMouseUp={() => onLineActivate(displayIndex)}
260
299
  >
261
- {runs.map((run, runIndex) => (
262
- <span key={runIndex} {...runStyle(run, block, tokens)}>
263
- {run.text}
264
- </span>
265
- ))}
300
+ {runs.map((run, runIndex) => {
301
+ const url = run.role === "link" ? safeLinkHref(run.href) : undefined;
302
+ return (
303
+ <span
304
+ key={runIndex}
305
+ {...runStyle(run, block, tokens)}
306
+ {...(url ? { link: { url } } : {})}
307
+ >
308
+ {run.text}
309
+ </span>
310
+ );
311
+ })}
266
312
  {showsChangeTag(block) ? (
267
313
  <span fg={tagColor(block, tokens)}> [{tagLabel(block)}]</span>
268
314
  ) : null}
269
315
  </text>
270
- {popover && popover.displayIndex === displayIndex ? (
271
- // float the card over the block, centered, with a one-row gap; flip
272
- // below only when there is no room above (near the viewport top)
273
- <box
274
- style={{
275
- position: "absolute",
276
- left: popoverLeft,
277
- // fixed offset: the toolbar sits one row above the selection and
278
- // stays put; the dropdown flows down from it, over the selection
279
- top: popoverFlipBelow ? 1 : -4,
280
- flexDirection: "column",
281
- }}
282
- >
283
- <MarkerPopover {...popover} theme={theme} />
284
- </box>
285
- ) : null}
286
316
  </box>,
287
317
  );
288
318
  }
@@ -298,9 +328,26 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
298
328
  );
299
329
  }
300
330
  }
331
+ if (popover) {
332
+ // last child on purpose: later siblings paint on top, so the floating card
333
+ // covers every block it overlaps regardless of scroll position
334
+ children.push(
335
+ <box
336
+ key="marker-popover"
337
+ style={{
338
+ position: "absolute",
339
+ left: popoverLeft,
340
+ top: popoverTop,
341
+ flexDirection: "column",
342
+ }}
343
+ >
344
+ <MarkerPopover {...popover} theme={theme} />
345
+ </box>,
346
+ );
347
+ }
301
348
 
302
349
  return (
303
- <box style={{ flexGrow: 1, flexDirection: "column" }}>
350
+ <box style={{ flexGrow: 1, flexDirection: "column" }} onMouseUp={onSelectionRelease}>
304
351
  <box
305
352
  style={{
306
353
  flexGrow: 1,
@@ -366,15 +413,25 @@ function runStyle(
366
413
  ): { fg?: string; bg?: string; attributes?: number } {
367
414
  // a cut block reads as removed: every run struck through and grayed, never red
368
415
  if (block.type === "del") return { fg: tokens.textDim, attributes: CUT_ATTRIBUTES };
416
+ const isHeading = block.kind === "h1" || block.kind === "h2" || block.kind === "h3";
417
+ const isQuote = block.kind === "quote";
418
+ // headings are all bold; level reads from descending brightness alone (a
419
+ // terminal cannot scale font size), leaving the salmon accent to annotations
369
420
  const headingFg =
370
421
  block.kind === "h1"
371
422
  ? tokens.text
372
- : block.kind === "h2" || block.kind === "h3"
373
- ? tokens.accent
374
- : undefined;
423
+ : block.kind === "h2"
424
+ ? tokens.textMuted
425
+ : block.kind === "h3"
426
+ ? tokens.textDim
427
+ : undefined;
428
+ // block-level base: headings bold, quotes muted italic; inline roles compose on top
429
+ const baseFg = headingFg ?? (isQuote ? tokens.textMuted : tokens.text);
430
+ const baseAttributes = (isHeading ? HEADING_ATTRIBUTES : 0) | (isQuote ? QUOTE_ATTRIBUTES : 0);
431
+ const withBase = (roleAttributes: number): number => baseAttributes | roleAttributes;
375
432
  switch (run.role) {
376
433
  case "ins":
377
- return { fg: tokens.insertedForeground };
434
+ return { fg: tokens.insertedForeground, attributes: baseAttributes || undefined };
378
435
  case "del":
379
436
  return { fg: tokens.deletedForeground };
380
437
  case "mark-comment":
@@ -383,7 +440,17 @@ function runStyle(
383
440
  return { fg: tokens.accentInk, bg: tokens.accent };
384
441
  case "kspan":
385
442
  return { fg: tokens.accentInk, bg: tokens.accent };
443
+ case "strong":
444
+ return { fg: baseFg, attributes: withBase(STRONG_ATTRIBUTES) };
445
+ case "em":
446
+ return { fg: baseFg, attributes: withBase(EM_ATTRIBUTES) };
447
+ case "code":
448
+ return { fg: tokens.text, bg: tokens.elevated };
449
+ case "strike":
450
+ return { fg: tokens.textMuted, attributes: withBase(STRIKE_ATTRIBUTES) };
451
+ case "link":
452
+ return { fg: tokens.blue, attributes: withBase(LINK_ATTRIBUTES) };
386
453
  default:
387
- return { fg: headingFg ?? tokens.text };
454
+ return { fg: baseFg, attributes: baseAttributes || undefined };
388
455
  }
389
456
  }
@@ -177,8 +177,6 @@ export const SubmitConfirmOpen: Story = {
177
177
  submitConfirm={{
178
178
  verdict: "request_changes",
179
179
  summary: "",
180
- annotationCount: 2,
181
- blockingCount: 0,
182
180
  onInput: () => {},
183
181
  onSelectVerdict: () => {},
184
182
  onSubmit: () => {},
@@ -75,11 +75,6 @@ export interface ReviewRailProps {
75
75
  theme?: Theme;
76
76
  }
77
77
 
78
- /** Forward-compatible: open annotation kinds may carry a blocking flag. */
79
- export function annotationBlocking(annotation: Annotation): boolean {
80
- return (annotation as Annotation & { blocking?: boolean }).blocking === true;
81
- }
82
-
83
78
  export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function ReviewRail(
84
79
  {
85
80
  session,
@@ -133,15 +128,13 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
133
128
  body: annotation.body,
134
129
  isSelected: annotation.id === selectedId,
135
130
  isOrphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
136
- isBlocking: annotationBlocking(annotation),
137
131
  // border-title author: a collaborator's name, "agent" for agent notes, else "me"
138
132
  author: annotation.author
139
133
  ? resolveDisplayName(annotation.author, session.participants, authorNames)
140
134
  : isAgentNote(annotation)
141
135
  ? "agent"
142
136
  : "me",
143
- // own + agent notes read as salmon; a share collaborator's note as blue
144
- tone: annotation.author && !isAgentNote(annotation) ? tokens.blue : tokens.accent,
137
+ tone: tokens.blue,
145
138
  editing:
146
139
  cardEdit && cardEdit.id === annotation.id
147
140
  ? {
@@ -296,7 +289,7 @@ function RemovalCard({
296
289
  title={title}
297
290
  contentRows={contentRows}
298
291
  borderColor={tokens.red}
299
- backgroundColor={isSelected ? tokens.elevated : "transparent"}
292
+ backgroundColor="transparent"
300
293
  theme={theme}
301
294
  >
302
295
  {previewLines.length === 0 ? (
@@ -84,8 +84,8 @@ exports[`stories catalog AnnotationCard/SavedEditing renders, snapshots, and car
84
84
  "
85
85
  `;
86
86
 
87
- exports[`stories catalog AnnotationCard/SavedOrphanBlocking renders, snapshots, and carries its colors: AnnotationCard/SavedOrphanBlocking 1`] = `
88
- "╭─ COMMENT · me · BLOCKING · ORPHANED ─────────────────────────────────────────╮
87
+ exports[`stories catalog AnnotationCard/SavedOrphan renders, snapshots, and carries its colors: AnnotationCard/SavedOrphan 1`] = `
88
+ "╭─ COMMENT · me · ORPHANED ────────────────────────────────────────────────────╮
89
89
  │ "a removed passage" │
90
90
  │ The passage is gone. │
91
91
  ╰──────────────────────────────────────────────────────────────────────────────╯
@@ -338,8 +338,6 @@ exports[`stories catalog CompletionOverlay/FeedbackCounting renders, snapshots,
338
338
 
339
339
  exports[`stories catalog ConfirmCard/ApproveDefault renders, snapshots, and carries its colors: ConfirmCard/ApproveDefault 1`] = `
340
340
  "╭─ submit review ─────────────────────╮
341
- │ 0 annotations · 0 blocking │
342
- │ │
343
341
  │ Comment [Approve] Changes │
344
342
  │ │
345
343
  │ summary for the agent (optional) │
@@ -351,13 +349,13 @@ exports[`stories catalog ConfirmCard/ApproveDefault renders, snapshots, and carr
351
349
 
352
350
 
353
351
 
352
+
353
+
354
354
  "
355
355
  `;
356
356
 
357
357
  exports[`stories catalog ConfirmCard/RequestChanges renders, snapshots, and carries its colors: ConfirmCard/RequestChanges 1`] = `
358
358
  "╭─ submit review ─────────────────────╮
359
- │ 3 annotations · 1 blocking │
360
- │ │
361
359
  │ Comment Approve [Changes] │
362
360
  │ │
363
361
  │ Tighten the steps. │
@@ -369,12 +367,13 @@ exports[`stories catalog ConfirmCard/RequestChanges renders, snapshots, and carr
369
367
 
370
368
 
371
369
 
370
+
371
+
372
372
  "
373
373
  `;
374
374
 
375
375
  exports[`stories catalog ConfirmCard/WithViewedSummary renders, snapshots, and carries its colors: ConfirmCard/WithViewedSummary 1`] = `
376
376
  "╭─ submit review ─────────────────────╮
377
- │ 1 annotations · 0 blocking │
378
377
  │ 2/3 files viewed │
379
378
  │ │
380
379
  │ Comment [Approve] Changes │
@@ -387,6 +386,7 @@ exports[`stories catalog ConfirmCard/WithViewedSummary renders, snapshots, and c
387
386
 
388
387
 
389
388
 
389
+
390
390
  "
391
391
  `;
392
392
 
@@ -1121,9 +1121,9 @@ exports[`stories catalog ReviewRail/SubmitConfirmOpen renders, snapshots, and ca
1121
1121
 
1122
1122
 
1123
1123
 
1124
+
1125
+
1124
1126
  ╭─ submit review ───────────────╮
1125
- │ 2 annotations · 0 blocking │
1126
- │ │
1127
1127
  │ Comment Approve [Changes] │
1128
1128
  │ │
1129
1129
  │ summary for the agent (optiona│
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ blockRuns,
4
+ buildDisplay,
5
+ displayText,
6
+ renderedOffsetFor,
7
+ workRangeForRendered,
8
+ } from "./view-plan";
9
+
10
+ // M0 (#232): lock the renderer's current StyleRun output and offset round-trips
11
+ // before the inline-tokenizer milestones (M1-M5) change blockRuns. The snapshots
12
+ // capture today's runs, so a later milestone's diff is explicit and reviewable.
13
+ // The round-trip assertions are the durable invariant: a visible word's rendered
14
+ // selection must always recover that word's *source* (block.text) offsets, even
15
+ // once inline markers are concealed - this is what keeps quote anchors exact.
16
+
17
+ const DOC = `# Heading with **bold**
18
+
19
+ A paragraph with **strong**, *em*, \`code\`, ~~strike~~ and a [label](https://example.com).
20
+
21
+ - a list item with *emphasis*
22
+ `;
23
+
24
+ const blocks = buildDisplay(DOC);
25
+
26
+ describe("view-plan regression harness (M0)", () => {
27
+ test("blockRuns output is stable across the representative blocks", () => {
28
+ // Arrange / Act
29
+ const runsByBlock = blocks.map((block) => ({
30
+ type: block.type,
31
+ text: displayText(block),
32
+ runs: blockRuns(block, true),
33
+ }));
34
+
35
+ // Assert
36
+ expect(runsByBlock).toMatchSnapshot();
37
+ });
38
+
39
+ // A visible word's source offsets survive the rendered round-trip. Holds at M0
40
+ // (1:1 today) and must keep holding once M2 conceals markers and the rendered
41
+ // offset shifts - the recovered work range stays the word's block.text span.
42
+ const paragraph = blocks.find((block) => displayText(block).includes("strong"))!;
43
+ const paragraphRuns = blockRuns(paragraph, true);
44
+ const paragraphText = displayText(paragraph);
45
+
46
+ for (const word of ["paragraph", "strong", "strike", "label"]) {
47
+ test(`rendered selection of "${word}" recovers its block.text offsets`, () => {
48
+ // Arrange
49
+ const start = paragraphText.indexOf(word);
50
+ const rendered = renderedOffsetFor(paragraphRuns, start);
51
+
52
+ // Assert
53
+ expect(rendered).not.toBeNull();
54
+ expect(workRangeForRendered(paragraphRuns, rendered!, rendered! + word.length)).toEqual({
55
+ start,
56
+ end: start + word.length,
57
+ });
58
+ });
59
+ }
60
+ });
@@ -6,7 +6,10 @@ import {
6
6
  marksByDisplay,
7
7
  nextWorkBlock,
8
8
  overlayMarks,
9
+ renderedOffsetAtOrAfter,
10
+ renderedOffsetAtOrBefore,
9
11
  renderedOffsetFor,
12
+ safeLinkHref,
10
13
  spanFromRange,
11
14
  spanKey,
12
15
  startSpan,
@@ -125,6 +128,86 @@ describe("blockRuns + overlayMarks", () => {
125
128
  });
126
129
  });
127
130
 
131
+ describe("M4: link hrefs are scheme-validated for OSC 8 (#236)", () => {
132
+ test("trusts http(s) and mailto, drops everything else", () => {
133
+ // Assert
134
+ expect(safeLinkHref("https://example.com")).toBe("https://example.com");
135
+ expect(safeLinkHref("http://x")).toBe("http://x");
136
+ expect(safeLinkHref("mailto:a@b.c")).toBe("mailto:a@b.c");
137
+ expect(safeLinkHref("javascript:alert(1)")).toBeUndefined();
138
+ expect(safeLinkHref("./relative/path")).toBeUndefined();
139
+ expect(safeLinkHref(undefined)).toBeUndefined();
140
+ });
141
+
142
+ test("a control byte or space in the target drops the link", () => {
143
+ // Assert - ESC and BEL could splice into the OSC 8 escape stream
144
+ expect(safeLinkHref("https://x\x1b]8;;evil\x07")).toBeUndefined();
145
+ expect(safeLinkHref("https://x\x07")).toBeUndefined();
146
+ expect(safeLinkHref("https://x y")).toBeUndefined();
147
+ });
148
+
149
+ test("blockRuns carries the link href through to the link run", () => {
150
+ // Act
151
+ const block = buildDisplay("see [docs](https://example.com) now\n")[0]!;
152
+ const link = blockRuns(block, true).find((run) => run.role === "link")!;
153
+
154
+ // Assert
155
+ expect(link.text).toBe("docs");
156
+ expect(link.href).toBe("https://example.com");
157
+ });
158
+ });
159
+
160
+ describe("M3: inline emphasis composes with word-diff (#235)", () => {
161
+ test("context emphasis survives; added text keeps the green diff role; markers concealed", () => {
162
+ // Arrange - "**this**" is unchanged (context, stays emphasized); "safe"->"sound" is the edit
163
+ const block = buildDisplay("keep **this** safe\n", "keep **this** sound\n")[0]!;
164
+
165
+ // Act
166
+ const runs = blockRuns(block, true);
167
+
168
+ // Assert - no marker text renders anywhere
169
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
170
+ // the unchanged emphasized word keeps its strong role (context, not diffed)
171
+ expect(runs.find((run) => run.text === "this")!.role).toBe("strong");
172
+ // the added word is green (ins wins over any emphasis), the removed one opaque
173
+ expect(runs.find((run) => run.text.includes("sound"))!.role).toBe("ins");
174
+ expect(runs.some((run) => run.role === "del" && run.start === null)).toBe(true);
175
+ });
176
+
177
+ test("markers inside added text conceal while the run stays a positioned ins", () => {
178
+ // Arrange - an emphasized word is added into an otherwise unchanged line
179
+ const block = buildDisplay("keep it safe\n", "keep it **very** safe\n")[0]!;
180
+
181
+ // Act
182
+ const runs = blockRuns(block, true);
183
+ const added = runs.find((run) => run.text === "very")!;
184
+
185
+ // Assert - concealed markers, green role, and an exact work offset back into work text
186
+ expect(added.role).toBe("ins");
187
+ expect(added.start).not.toBeNull();
188
+ expect(block.work!.text.slice(added.start!, added.start! + "very".length)).toBe("very");
189
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
190
+ });
191
+
192
+ test("an anchor over an emphasized context word round-trips to its work offsets", () => {
193
+ // Arrange
194
+ const block = buildDisplay("hold **steady** now\n", "hold **steady** later\n")[0]!;
195
+ const runs = blockRuns(block, true);
196
+ const work = block.work!.text;
197
+ const start = work.indexOf("steady");
198
+
199
+ // Act
200
+ const rendered = renderedOffsetFor(runs, start);
201
+
202
+ // Assert
203
+ expect(rendered).not.toBeNull();
204
+ expect(workRangeForRendered(runs, rendered!, rendered! + "steady".length)).toEqual({
205
+ start,
206
+ end: start + "steady".length,
207
+ });
208
+ });
209
+ });
210
+
128
211
  describe("marksByDisplay", () => {
129
212
  test("annotations resolve into display coordinates", () => {
130
213
  // Arrange
@@ -217,6 +300,28 @@ describe("rendered/work offset mapping", () => {
217
300
  expect(renderedOffsetFor(runs, 99)).toBeNull();
218
301
  expect(workRangeForRendered(runs, 90, 99)).toBeNull();
219
302
  });
303
+
304
+ test("a span starting/ending on concealed markers snaps to the visible word", () => {
305
+ // Arrange - the word span over "**very**" covers the markers, which have
306
+ // no rendered cells; the clamped lookups land on "very" itself
307
+ const text = "keep it **very** safe\n";
308
+ const runs = blockRuns(buildDisplay(text)[0]!, true);
309
+ const spanStart = text.indexOf("**very**");
310
+ const spanEnd = spanStart + "**very**".length;
311
+
312
+ // Act
313
+ const renderedStart = renderedOffsetAtOrAfter(runs, spanStart)!;
314
+ const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
315
+
316
+ // Assert
317
+ const renderedText = runs.map((run) => run.text).join("");
318
+ expect(renderedText.slice(renderedStart, renderedEnd + 1)).toBe("very");
319
+ // exact hits behave like renderedOffsetFor
320
+ expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
321
+ expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
322
+ // nothing positioned beyond the text end
323
+ expect(renderedOffsetAtOrAfter(runs, 99)).toBeNull();
324
+ });
220
325
  });
221
326
 
222
327
  describe("span mode", () => {
package/src/view-plan.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import {
11
+ inlineRuns,
11
12
  isAddressed,
12
13
  lcsDiff,
13
14
  parseBlocks,
@@ -141,7 +142,18 @@ function numberOrderedListItems(display: DisplayBlock[]): void {
141
142
 
142
143
  // ── marks (annotation highlights + keyboard span) ──
143
144
 
144
- export type RunRole = "plain" | "ins" | "del" | "mark-comment" | "kspan" | "mark-focus";
145
+ export type RunRole =
146
+ | "plain"
147
+ | "ins"
148
+ | "del"
149
+ | "mark-comment"
150
+ | "kspan"
151
+ | "mark-focus"
152
+ | "strong"
153
+ | "em"
154
+ | "code"
155
+ | "strike"
156
+ | "link";
145
157
 
146
158
  export interface StyleRun {
147
159
  text: string;
@@ -149,6 +161,8 @@ export interface StyleRun {
149
161
  /** Offset of this run in the block's working text; null for del runs. */
150
162
  start: number | null;
151
163
  annotationId?: string;
164
+ /** Link target for `link` runs. */
165
+ href?: string;
152
166
  }
153
167
 
154
168
  export interface Mark {
@@ -195,6 +209,7 @@ export function marksByDisplay(
195
209
 
196
210
  /** Base runs for a display block: plain text, or word-diff for mod blocks. */
197
211
  export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
212
+ const literal = block.kind === "code" || block.kind === "hr";
198
213
  if (block.type === "mod" && markup) {
199
214
  const changes = wordLevelChanges(block.base!.text, block.work!.text);
200
215
  const runs: StyleRun[] = [];
@@ -202,18 +217,57 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
202
217
  for (const change of changes) {
203
218
  if (change.kind === "removed") {
204
219
  runs.push({ text: change.text, role: "del", start: null });
205
- } else {
206
- runs.push({
207
- text: change.text,
208
- role: change.kind === "added" ? "ins" : "plain",
209
- start: workOffset,
210
- });
211
- workOffset += change.text.length;
220
+ continue;
212
221
  }
222
+ // A code/rule block stays literal; prose segments inline-tokenize so their
223
+ // markers conceal while their work offsets stay intact. Added text keeps
224
+ // the green diff role (it wins over emphasis); context text shows emphasis.
225
+ const segment = literal
226
+ ? [{ text: change.text, role: "plain" as const, start: workOffset }]
227
+ : inlineStyleRuns(change.text, workOffset);
228
+ for (const run of segment) {
229
+ runs.push(change.kind === "added" ? { ...run, role: "ins" } : run);
230
+ }
231
+ workOffset += change.text.length;
213
232
  }
214
233
  return runs;
215
234
  }
216
- return [{ text: displayText(block), role: "plain", start: 0 }];
235
+ const text = displayText(block);
236
+ // Prose carries inline markup; code fences and rules are literal. Concealed
237
+ // markers are dropped, so every rendered cell still maps to a work offset.
238
+ if (markup && !literal) {
239
+ return inlineStyleRuns(text, 0);
240
+ }
241
+ return [{ text, role: "plain", start: 0 }];
242
+ }
243
+
244
+ /** A link href safe to hand a terminal as an OSC 8 target, or undefined. Only
245
+ * http(s) and mailto are trusted; a scheme like javascript: is dropped. The
246
+ * target must stay printable ASCII: a control byte (ESC, BEL) in a reviewed
247
+ * document could otherwise splice into the terminal escape stream. */
248
+ export function safeLinkHref(href: string | undefined): string | undefined {
249
+ if (href === undefined) return undefined;
250
+ if (!/^(https?:|mailto:)/i.test(href)) return undefined;
251
+ return /^[\x21-\x7e]+$/.test(href) ? href : undefined;
252
+ }
253
+
254
+ /**
255
+ * Map the schema inline tokens to positioned StyleRuns, dropping the concealed
256
+ * markers. `base` is the run's offset within the block's working text (0 for a
257
+ * whole prose block; a segment's start for a word-diff piece).
258
+ */
259
+ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
260
+ const runs: StyleRun[] = [];
261
+ for (const run of inlineRuns(text)) {
262
+ if (run.role === "marker" || run.start === null) continue;
263
+ runs.push({
264
+ text: run.text,
265
+ role: run.role === "text" ? "plain" : run.role,
266
+ start: base + run.start,
267
+ ...(run.href !== undefined ? { href: run.href } : {}),
268
+ });
269
+ }
270
+ return runs;
217
271
  }
218
272
 
219
273
  /** Split runs at mark boundaries; marks only bind to runs with offsets. */
@@ -274,6 +328,39 @@ export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number
274
328
  return null;
275
329
  }
276
330
 
331
+ /**
332
+ * Rendered offset of the first positioned character at or after `workOffset`.
333
+ * A word span may start on a concealed marker (the `**` of an emphasized
334
+ * word), which has no rendered cell; the span visuals snap to the first
335
+ * character that does. Positioned runs appear in ascending work order.
336
+ */
337
+ export function renderedOffsetAtOrAfter(runs: StyleRun[], workOffset: number): number | null {
338
+ let rendered = 0;
339
+ for (const run of runs) {
340
+ if (run.start !== null && run.start + run.text.length > workOffset) {
341
+ return rendered + Math.max(0, workOffset - run.start);
342
+ }
343
+ rendered += run.text.length;
344
+ }
345
+ return null;
346
+ }
347
+
348
+ /**
349
+ * Rendered offset of the last positioned character at or before `workOffset` -
350
+ * the closing-marker counterpart of `renderedOffsetAtOrAfter`.
351
+ */
352
+ export function renderedOffsetAtOrBefore(runs: StyleRun[], workOffset: number): number | null {
353
+ let rendered = 0;
354
+ let best: number | null = null;
355
+ for (const run of runs) {
356
+ if (run.start !== null && run.start <= workOffset) {
357
+ best = rendered + Math.min(workOffset - run.start, run.text.length - 1);
358
+ }
359
+ rendered += run.text.length;
360
+ }
361
+ return best;
362
+ }
363
+
277
364
  /**
278
365
  * Work-text range covered by a rendered-text selection [start, end). Del runs
279
366
  * and other unpositioned text inside the selection contribute nothing; the