@shiyuphan/follow-me 0.2.0 → 0.4.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 (154) hide show
  1. package/README.md +44 -55
  2. package/dist/src/cli.js +740 -194
  3. package/dist/src/comments.js +109 -6
  4. package/dist/src/diff.js +11 -0
  5. package/dist/src/evolve.js +123 -0
  6. package/dist/src/git.js +12 -0
  7. package/dist/src/host.js +39 -8
  8. package/dist/src/install.js +21 -0
  9. package/dist/src/plan.js +1 -1
  10. package/dist/src/prompt.js +384 -51
  11. package/dist/src/server.js +195 -67
  12. package/dist/src/session.js +53 -12
  13. package/dist/ui/dist/assets/abnfDiagram-WEYOIA3C-byISxePP.js +1 -0
  14. package/dist/ui/dist/assets/arc-CBzsAsP2.js +1 -0
  15. package/dist/ui/dist/assets/architecture-WOLXFQ4H-0OSkIwdB.js +1 -0
  16. package/dist/ui/dist/assets/architectureDiagram-ZHVYKT53-ixHybNqO.js +36 -0
  17. package/dist/ui/dist/assets/array-BifhSqXX.js +1 -0
  18. package/dist/ui/dist/assets/blockDiagram-RJAE7QA3-1F8XL_Ou.js +140 -0
  19. package/dist/ui/dist/assets/c4Diagram-XDPPPE54-D9PFBXnc.js +38 -0
  20. package/dist/ui/dist/assets/channel-B-fwsHf1.js +1 -0
  21. package/dist/ui/dist/assets/chunk-2E7WHVMD-zZdNfMkj.js +88 -0
  22. package/dist/ui/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  23. package/dist/ui/dist/assets/chunk-4EMNKR2T-CTKoxSSj.js +1 -0
  24. package/dist/ui/dist/assets/chunk-55XCU76K-ifFnDlcS.js +1 -0
  25. package/dist/ui/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  26. package/dist/ui/dist/assets/chunk-7S6XSTFO-BCMVdi_1.js +2 -0
  27. package/dist/ui/dist/assets/chunk-AQDTSKZB-DC-5Whju.js +72 -0
  28. package/dist/ui/dist/assets/chunk-DSNLSHJZ-DASbe_gb.js +127 -0
  29. package/dist/ui/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  30. package/dist/ui/dist/assets/chunk-HAGFOTED-CArytDjj.js +62 -0
  31. package/dist/ui/dist/assets/chunk-HNE4Q36P-Dnl-Wtud.js +1 -0
  32. package/dist/ui/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  33. package/dist/ui/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  34. package/dist/ui/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  35. package/dist/ui/dist/assets/chunk-NQOKRVIF-wvSLaeaK.js +213 -0
  36. package/dist/ui/dist/assets/chunk-UHW7PFBP-CckaOGiQ.js +1 -0
  37. package/dist/ui/dist/assets/chunk-VQ5W62QI-kQBMDJ7V.js +1 -0
  38. package/dist/ui/dist/assets/chunk-WQQL4CDK-CMg6bbLU.js +10 -0
  39. package/dist/ui/dist/assets/chunk-XXDRQBXY-CYlFr19K.js +1 -0
  40. package/dist/ui/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  41. package/dist/ui/dist/assets/chunk-YZO67HFG-CqkG1vVk.js +1 -0
  42. package/dist/ui/dist/assets/chunk-ZAUX2LTX-BUNEdx-e.js +1 -0
  43. package/dist/ui/dist/assets/chunk-ZCHMQSOR-CFTbvcrm.js +2 -0
  44. package/dist/ui/dist/assets/classDiagram-v2-4MYWR3UA-t2OdJ0LA.js +217 -0
  45. package/dist/ui/dist/assets/cose-bilkent-JH36ORCC-FGm030gc.js +1 -0
  46. package/dist/ui/dist/assets/cynefin-EF2NZ3EQ-BtU33Lsu.js +1 -0
  47. package/dist/ui/dist/assets/cynefinDiagram-WF24UDRP-DUr-I7hS.js +62 -0
  48. package/dist/ui/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  49. package/dist/ui/dist/assets/dagre-NK4GIZVX-BWTHwJKt.js +4 -0
  50. package/dist/ui/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  51. package/dist/ui/dist/assets/diagram-CBK3MWTC-PcHrsEBn.js +24 -0
  52. package/dist/ui/dist/assets/diagram-I5XIIV2D-CkGtKjYj.js +24 -0
  53. package/dist/ui/dist/assets/diagram-ODIL2UCW-B_HdBB1w.js +30 -0
  54. package/dist/ui/dist/assets/diagram-PFBEJUXE-DL9zayE6.js +3 -0
  55. package/dist/ui/dist/assets/diagram-RJA3I4VV-By5wl8nK.js +41 -0
  56. package/dist/ui/dist/assets/diagram-TYUNFSNR-ziWPVayL.js +200 -0
  57. package/dist/ui/dist/assets/dist-NDVqCM7k.js +1 -0
  58. package/dist/ui/dist/assets/ebnfDiagram-V4P3TTPT-DH4YGkPc.js +1 -0
  59. package/dist/ui/dist/assets/elk-QONTBCTQ-BL2RlCEv.js +27 -0
  60. package/dist/ui/dist/assets/erDiagram-KYQIGQAA-BKbTMaRp.js +99 -0
  61. package/dist/ui/dist/assets/eventmodeling-K75KTNOO-Dk5RCWc-.js +1 -0
  62. package/dist/ui/dist/assets/flowDiagram-IUGLLWBU-Dkjb3PCK.js +1 -0
  63. package/dist/ui/dist/assets/ganttDiagram-R7GPNTJY-3vM9gxeT.js +292 -0
  64. package/dist/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  65. package/dist/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  66. package/dist/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  67. package/dist/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  68. package/dist/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  69. package/dist/ui/dist/assets/gitGraph-VSP46ZUC-CXGFhz54.js +1 -0
  70. package/dist/ui/dist/assets/gitGraphDiagram-PDYL7XHY-Cl4fwUuA.js +106 -0
  71. package/dist/ui/dist/assets/graphlib-DS17s2tU.js +1 -0
  72. package/dist/ui/dist/assets/index-B8TYcxF8.css +1 -0
  73. package/dist/ui/dist/assets/index-CBbx_pJk.js +134 -0
  74. package/dist/ui/dist/assets/info-OHQRW6UA-DfSsZ8ib.js +1 -0
  75. package/dist/ui/dist/assets/infoDiagram-MVDRKAX5-B_7tyrL5.js +2 -0
  76. package/dist/ui/dist/assets/init-C-OQMol4.js +1 -0
  77. package/dist/ui/dist/assets/ishikawaDiagram-HPEHV2GE-DUvHljpg.js +70 -0
  78. package/dist/ui/dist/assets/journeyDiagram-BOSHW7O7-BCgqzrb8.js +139 -0
  79. package/dist/ui/dist/assets/kanban-definition-FNSA4HC5-DOzZ3NmE.js +89 -0
  80. package/dist/ui/dist/assets/katex-B_JcIwqj.js +257 -0
  81. package/dist/ui/dist/assets/line-B-mPFVll.js +1 -0
  82. package/dist/ui/dist/assets/linear-k7o0jout.js +1 -0
  83. package/dist/ui/dist/assets/mermaid-parser.core-CAyLSQpH.js +7 -0
  84. package/dist/ui/dist/assets/mermaid.core-vfVMjgx2.js +44 -0
  85. package/dist/ui/dist/assets/mindmap-definition-6OY57UG3-B6y-B_r4.js +98 -0
  86. package/dist/ui/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  87. package/dist/ui/dist/assets/packet-JDAUHWVQ-C_r4ed30.js +1 -0
  88. package/dist/ui/dist/assets/path-fybaL0A-.js +1 -0
  89. package/dist/ui/dist/assets/pegDiagram-ECIUJNBX-B8VUm3Bh.js +1 -0
  90. package/dist/ui/dist/assets/pie-XZMESJXO-DcwtbH9y.js +1 -0
  91. package/dist/ui/dist/assets/pieDiagram-GNMPY74T-CvUNBi2F.js +39 -0
  92. package/dist/ui/dist/assets/prism-bash-D6zCJ74D.js +1 -0
  93. package/dist/ui/dist/assets/prism-c-04YixN25.js +1 -0
  94. package/dist/ui/dist/assets/prism-clike-DapgWyxx.js +1 -0
  95. package/dist/ui/dist/assets/prism-core-_2ju85Ya.js +1 -0
  96. package/dist/ui/dist/assets/prism-cpp-C-lJbC-6.js +1 -0
  97. package/dist/ui/dist/assets/prism-css-CPgOzxXp.js +1 -0
  98. package/dist/ui/dist/assets/prism-go-C_8qAH5n.js +1 -0
  99. package/dist/ui/dist/assets/prism-java-6wtusvIl.js +1 -0
  100. package/dist/ui/dist/assets/prism-javascript-BnO-swvr.js +1 -0
  101. package/dist/ui/dist/assets/prism-json-Dp_-W-Hv.js +1 -0
  102. package/dist/ui/dist/assets/prism-jsx-D-D0NWtC.js +1 -0
  103. package/dist/ui/dist/assets/prism-kotlin-BUlJgC-o.js +1 -0
  104. package/dist/ui/dist/assets/prism-markdown-Bc9-36OC.js +1 -0
  105. package/dist/ui/dist/assets/prism-markup-Cp04rIa1.js +1 -0
  106. package/dist/ui/dist/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
  107. package/dist/ui/dist/assets/prism-php-Co4fM40K.js +1 -0
  108. package/dist/ui/dist/assets/prism-python-CfiSVtM6.js +1 -0
  109. package/dist/ui/dist/assets/prism-ruby-DFCASkuD.js +1 -0
  110. package/dist/ui/dist/assets/prism-rust-Bu9N8X3N.js +1 -0
  111. package/dist/ui/dist/assets/prism-sql-CYhVDMEE.js +1 -0
  112. package/dist/ui/dist/assets/prism-swift-B0kWc-ne.js +1 -0
  113. package/dist/ui/dist/assets/prism-toml-DKb54GAT.js +1 -0
  114. package/dist/ui/dist/assets/prism-tsx-p6J2Kc_4.js +1 -0
  115. package/dist/ui/dist/assets/prism-typescript-BMqONzxf.js +1 -0
  116. package/dist/ui/dist/assets/prism-yaml-C0JHu8gr.js +1 -0
  117. package/dist/ui/dist/assets/quadrantDiagram-D57K4MC3-Dg2eVS3j.js +7 -0
  118. package/dist/ui/dist/assets/radar-ABXABTNO-xCNTcZE9.js +1 -0
  119. package/dist/ui/dist/assets/railroad-I3PHUGI6-BI-sIL2Q.js +1 -0
  120. package/dist/ui/dist/assets/railroad-abnf-LNEFI6M7-Ddvk40Wx.js +1 -0
  121. package/dist/ui/dist/assets/railroad-ebnf-DWYD2UWJ-h35gE183.js +1 -0
  122. package/dist/ui/dist/assets/railroad-peg-JR7BVNR7-Zim9iPdp.js +1 -0
  123. package/dist/ui/dist/assets/railroadDiagram-Y72NRBL7-Cz6fdmOQ.js +1 -0
  124. package/dist/ui/dist/assets/requirementDiagram-EQYSW5RA-DTJkCrcD.js +84 -0
  125. package/dist/ui/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  126. package/dist/ui/dist/assets/sankeyDiagram-SY24JHO5-Bg1iiVla.js +40 -0
  127. package/dist/ui/dist/assets/sequenceDiagram-BLEHNHQU-9XfetsRA.js +169 -0
  128. package/dist/ui/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  129. package/dist/ui/dist/assets/src-Dym5afQ5.js +1 -0
  130. package/dist/ui/dist/assets/stateDiagram-v2-A6IESKWZ-CMmcMV8z.js +291 -0
  131. package/dist/ui/dist/assets/swimlanes-B3ENRKFQ-B2PcshTY.js +1 -0
  132. package/dist/ui/dist/assets/swimlanesDiagram-AYQRV4AK-DfB3XTTn.js +8 -0
  133. package/dist/ui/dist/assets/timeline-definition-JXYKGLGB-Cr1hCNtW.js +120 -0
  134. package/dist/ui/dist/assets/treeView-D4JQ5SDB-jH5ksKnh.js +1 -0
  135. package/dist/ui/dist/assets/treemap-SAJKECNS-BV90e8QL.js +1 -0
  136. package/dist/ui/dist/assets/usecaseDiagram-WPOLBNNX-BNwq5aJV.js +328 -0
  137. package/dist/ui/dist/assets/vennDiagram-Q3Y3IDCW-D-lLyZCL.js +34 -0
  138. package/dist/ui/dist/assets/wardley-7MLQ67FV-CWfHQUGt.js +1 -0
  139. package/dist/ui/dist/assets/wardleyDiagram-IVAIDKWA-BL_qNKVd.js +78 -0
  140. package/dist/ui/dist/assets/xychartDiagram-4URNHDU4-BHPK5Snp.js +7 -0
  141. package/dist/ui/dist/index.html +15 -0
  142. package/dist/ui/dist/style.css +121 -0
  143. package/package.json +31 -10
  144. package/skills/follow-me/SKILL.md +19 -89
  145. package/dist/src/anthropic.js +0 -133
  146. package/dist/ui/app.js +0 -2201
  147. package/dist/ui/concerns.js +0 -165
  148. package/dist/ui/highlight.js +0 -221
  149. package/dist/ui/index.html +0 -94
  150. package/dist/ui/live.js +0 -184
  151. package/dist/ui/playback.js +0 -579
  152. package/dist/ui/questions.js +0 -189
  153. package/dist/ui/style.css +0 -1331
  154. package/dist/ui/summary.js +0 -45
@@ -1,579 +0,0 @@
1
- // @ts-check
2
- // Pure playback logic. The browser imports it from /playback.js and the tests import it
3
- // directly, so nothing here may touch the DOM or speech APIs.
4
-
5
- /**
6
- * @typedef {{ op: " ", text: string } | { op: "+" | "-", text: string, change: number }} HunkLine
7
- * @typedef {{ mode: string, blob: string }} Entry
8
- * @typedef {{ kind: "hunk", id: string, path: string, oldStart?: number, body: HunkLine[] }
9
- * | { kind: "file", id: string, path: string, reason: string, before: Entry | null, after: Entry | null }
10
- * | { kind: "mode", id: string, path: string, from: string, to: string }} Unit
11
- * @typedef {{ kind: "rest" } | { kind: "lines", changes: number[] }} Selection
12
- * @typedef {{ unit: string, select: Selection, narration?: string, diagram?: string }} Ref
13
- * @typedef {{ index: number, message: string, narration?: string, diagram?: string, refs: Ref[] }} Commit
14
- *
15
- * A beat is one thing the reviewer attends to: a commit's intro, one chunk of its change, or the
16
- * held finished commit. lines are indices into the hunk body, removals the subset that are "-"
17
- * lines; f_ and m_ chunks have neither. added counts the non-blank "+" lines.
18
- * @typedef {{ kind: "intro", commit: number, message: string, narration: string | null }
19
- * | { kind: "chunk", commit: number, unit: string, lines: number[], narration: string | null,
20
- * removals: number[], label: string, added: number }
21
- * | { kind: "outro", commit: number, next: string | null }} Beat
22
- *
23
- * A step is a timed slice of a beat. "say" shows the narration card and speaks it, lasting ms or
24
- * until speech ends plus tail, whichever is later. "show" reveals a chunk's lines. "rest" is the
25
- * pause after a chunk, "hold" the finished commit.
26
- * @typedef {{ kind: "say", beat: number, text: string, ms: number, tail: number }
27
- * | { kind: "show" | "rest" | "hold", beat: number, ms: number }} Step
28
- *
29
- * @typedef {"hidden" | "shown" | "struck"} LineState
30
- * @typedef {{ name: string, lang: string, default: boolean }} VoiceInfo
31
- */
32
-
33
- export const SPEEDS = [0.75, 1, 1.5, 2];
34
-
35
- /** Context lines that may sit between two change lines of one chunk. */
36
- const MAX_GAP = 3;
37
- /** A longer run is cut at the first blank or closing line once it has this many lines. */
38
- const SPLIT_AFTER = 8;
39
- const MAX_CHUNK = 12;
40
-
41
- const BEAT_MS = 600;
42
- const HOLD_MS = 2500;
43
- const INTRO_TAIL_MS = 500;
44
- const MAX_SPEECH_RATE = 1.5;
45
- const WORDS_PER_SECOND = 2.5;
46
-
47
- /**
48
- * The key a diff line carries in the DOM, unit id plus its index in the hunk body.
49
- * @param {string} unit
50
- * @param {number} line
51
- */
52
- export function lineKey(unit, line) {
53
- return `${unit}:${line}`;
54
- }
55
-
56
- /** @param {string} message */
57
- export function titleOf(message) {
58
- return message.split("\n")[0] ?? "";
59
- }
60
-
61
- /** @param {string} text */
62
- function wordCount(text) {
63
- return text.split(/\s+/).filter(Boolean).length;
64
- }
65
-
66
- /**
67
- * Milliseconds to read a text at 1x: about 200 words a minute plus a second to settle.
68
- * @param {string} text
69
- */
70
- export function readMs(text) {
71
- return Math.max(2000, 1000 + 300 * wordCount(text));
72
- }
73
-
74
- /**
75
- * Milliseconds to take in a chunk at 1x. New code costs more to read than removed code.
76
- * @param {number} added non-blank added lines
77
- * @param {number} removed
78
- */
79
- export function codeMs(added, removed) {
80
- return Math.min(9000, Math.max(2000, 1500 + 400 * added + 200 * removed));
81
- }
82
-
83
- /** @param {string | undefined} text */
84
- function present(text) {
85
- const trimmed = text?.trim();
86
- return trimmed ? trimmed : null;
87
- }
88
-
89
- /** @param {readonly Commit[]} commits */
90
- export function hasNarration(commits) {
91
- return commits.some(
92
- (c) => present(c.narration) !== null || c.refs.some((r) => present(r.narration) !== null),
93
- );
94
- }
95
-
96
- /** @param {HunkLine | undefined} line */
97
- function textOf(line) {
98
- return (line?.text ?? "").replace(/\r?\n$/, "");
99
- }
100
-
101
- /**
102
- * Runs of a ref's own change lines with at most MAX_GAP other lines between them, each cut to at
103
- * most MAX_CHUNK lines, preferably right after a blank or closing line.
104
- * @param {readonly HunkLine[]} body
105
- * @param {ReadonlySet<number>} mine
106
- */
107
- function runsOf(body, mine) {
108
- /** @type {number[][]} */
109
- const runs = [];
110
- let gap = Number.POSITIVE_INFINITY;
111
- body.forEach((line, i) => {
112
- if (line.op === " " || !mine.has(line.change)) {
113
- gap += 1;
114
- return;
115
- }
116
- const last = runs.at(-1);
117
- if (last !== undefined && gap <= MAX_GAP) last.push(i);
118
- else runs.push([i]);
119
- gap = 0;
120
- });
121
- return runs.flatMap((run) => {
122
- /** @type {number[][]} */
123
- const pieces = [];
124
- let rest = run;
125
- while (rest.length > MAX_CHUNK) {
126
- const found = rest.findIndex(
127
- (i, k) => k >= SPLIT_AFTER && k < MAX_CHUNK && /^\s*[}\])]*[;,]?\s*$/.test(textOf(body[i])),
128
- );
129
- const cut = found === -1 ? MAX_CHUNK : found + 1;
130
- pieces.push(rest.slice(0, cut));
131
- rest = rest.slice(cut);
132
- }
133
- if (rest.length > 0) pieces.push(rest);
134
- return pieces;
135
- });
136
- }
137
-
138
- const DECLARATION =
139
- /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class|def|fn|func|interface|struct|enum|impl|trait|module)\s+([A-Za-z_$][\w$]*)/;
140
-
141
- /**
142
- * The function or class a hunk line sits in: the nearest declaration at or above it. Lines removed in the hunk are skipped; they no longer exist at the tip.
143
- * @param {readonly HunkLine[]} body
144
- * @param {number} last
145
- */
146
- export function scopeOf(body, last) {
147
- for (let i = last; i >= 0; i -= 1) {
148
- const line = body[i];
149
- if (line === undefined || line.op === "-") continue;
150
- const name = DECLARATION.exec(textOf(line))?.[1];
151
- if (name !== undefined) return name;
152
- }
153
- return null;
154
- }
155
-
156
- /** @param {number} n @param {string} word */
157
- function count(n, word) {
158
- return `${n} ${word}${n === 1 ? "" : "s"}`;
159
- }
160
-
161
- /**
162
- * @param {string} path
163
- * @param {number} added
164
- * @param {number} removed
165
- * @param {string | null} name
166
- */
167
- function hunkLabel(path, added, removed, name) {
168
- const what =
169
- removed === 0
170
- ? `adds ${count(added, "line")}`
171
- : added === 0
172
- ? `removes ${count(removed, "line")}`
173
- : `adds ${count(added, "line")}, removes ${removed}`;
174
- return `${path} · ${what}${name === null ? "" : ` in ${name}`}`;
175
- }
176
-
177
- /** @param {Exclude<Unit, { kind: "hunk" }>} unit */
178
- function wholeLabel(unit) {
179
- if (unit.kind === "mode") return `${unit.path} · mode ${unit.from} → ${unit.to}`;
180
- const change = unit.before === null ? "added" : unit.after === null ? "deleted" : "changed";
181
- return `${unit.path} · ${unit.reason} file ${change}`;
182
- }
183
-
184
- /**
185
- * The beats of the whole plan: per commit an intro, one chunk per run of its own change lines
186
- * (one per whole-file or mode unit), and an outro. A unit's narration goes on its first chunk.
187
- * @param {readonly Commit[]} commits
188
- * @param {readonly Unit[]} units
189
- * @returns {Beat[]}
190
- */
191
- export function buildScript(commits, units) {
192
- const byId = new Map(units.map((u) => [u.id, u]));
193
- /** @type {Beat[]} */
194
- const beats = [];
195
- commits.forEach((commit, k) => {
196
- beats.push({
197
- kind: "intro",
198
- commit: commit.index,
199
- message: commit.message,
200
- narration: present(commit.narration),
201
- });
202
- for (const ref of commit.refs) {
203
- const unit = byId.get(ref.unit);
204
- if (unit === undefined) continue;
205
- const narration = present(ref.narration);
206
- if (unit.kind !== "hunk") {
207
- beats.push({
208
- kind: "chunk",
209
- commit: commit.index,
210
- unit: unit.id,
211
- lines: [],
212
- removals: [],
213
- narration,
214
- label: wholeLabel(unit),
215
- added: 0,
216
- });
217
- continue;
218
- }
219
- const mine = new Set(
220
- ref.select.kind === "lines"
221
- ? ref.select.changes
222
- : unit.body.flatMap((l) => (l.op === " " ? [] : [l.change])),
223
- );
224
- runsOf(unit.body, mine).forEach((lines, n) => {
225
- const additions = lines.filter((i) => unit.body[i]?.op === "+");
226
- const removals = lines.filter((i) => unit.body[i]?.op === "-");
227
- const name = scopeOf(unit.body, lines.at(-1) ?? 0);
228
- beats.push({
229
- kind: "chunk",
230
- commit: commit.index,
231
- unit: unit.id,
232
- lines,
233
- removals,
234
- narration: n === 0 ? narration : null,
235
- label: hunkLabel(unit.path, additions.length, removals.length, name),
236
- added: additions.filter((i) => textOf(unit.body[i]).trim() !== "").length,
237
- });
238
- });
239
- }
240
- const next = commits[k + 1];
241
- beats.push({
242
- kind: "outro",
243
- commit: commit.index,
244
- next: next === undefined ? null : titleOf(next.message),
245
- });
246
- });
247
- return beats;
248
- }
249
-
250
- /**
251
- * The timed steps of the beats at a speed, which divides every dwell, beat and hold. A commit
252
- * without narration speaks its title and stays up long enough to read its whole message.
253
- * @param {readonly Beat[]} beats
254
- * @param {number} speed
255
- * @returns {Step[]}
256
- */
257
- export function schedule(beats, speed) {
258
- /** @type {(ms: number) => number} */
259
- const at = (ms) => Math.round(ms / speed);
260
- return beats.flatMap((beat, i) => {
261
- if (beat.kind === "intro") {
262
- const text = beat.narration ?? titleOf(beat.message);
263
- const ms = at(readMs(beat.narration ?? beat.message));
264
- return [{ kind: "say", beat: i, text, ms, tail: at(INTRO_TAIL_MS) }];
265
- }
266
- if (beat.kind === "outro") return [{ kind: "hold", beat: i, ms: at(HOLD_MS) }];
267
- /** @type {Step[]} */
268
- const steps = [];
269
- if (beat.narration !== null) {
270
- steps.push({
271
- kind: "say",
272
- beat: i,
273
- text: beat.narration,
274
- ms: at(readMs(beat.narration)),
275
- tail: 0,
276
- });
277
- }
278
- steps.push({ kind: "show", beat: i, ms: at(codeMs(beat.added, beat.removals.length)) });
279
- steps.push({ kind: "rest", beat: i, ms: at(BEAT_MS) });
280
- return steps;
281
- });
282
- }
283
-
284
- /**
285
- * Silent playing time of the steps, the floor of what playback takes with speech on.
286
- * @param {readonly Step[]} steps
287
- */
288
- export function duration(steps) {
289
- return steps.reduce((sum, s) => sum + s.ms, 0);
290
- }
291
-
292
- /**
293
- * Synthetic voices are hard to follow above 1.5x, so speech caps there while dwells keep scaling.
294
- * @param {number} speed
295
- */
296
- export function speechRate(speed) {
297
- return Math.min(speed, MAX_SPEECH_RATE);
298
- }
299
-
300
- /**
301
- * A generous upper bound on how long speaking the text takes, for when the browser never
302
- * reports the end of an utterance.
303
- * @param {string} text
304
- * @param {number} speed
305
- */
306
- export function speechTimeout(text, speed) {
307
- return (2 * 1000 * wordCount(text)) / (WORDS_PER_SECOND * speechRate(speed)) + 2000;
308
- }
309
-
310
- /**
311
- * The beat on screen at a step position; past the last step that is beats.length, the end.
312
- * @param {readonly Step[]} steps
313
- * @param {number} position
314
- */
315
- export function beatAt(steps, position) {
316
- const step = steps[position];
317
- if (step !== undefined) return step.beat;
318
- return (steps.at(-1)?.beat ?? -1) + 1;
319
- }
320
-
321
- /**
322
- * The first step of a beat, clamped to the steps. A beat past the end lands on the end.
323
- * @param {readonly Step[]} steps
324
- * @param {number} beat
325
- */
326
- export function beatStart(steps, beat) {
327
- if (beat < 0) return 0;
328
- const at = steps.findIndex((s) => s.beat === beat);
329
- return at === -1 ? steps.length : at;
330
- }
331
-
332
- /**
333
- * The commit a beat belongs to; the end belongs to the last commit.
334
- * @param {readonly Beat[]} beats
335
- * @param {number} beat
336
- */
337
- export function commitOf(beats, beat) {
338
- return (beats[Math.min(beat, beats.length - 1)] ?? beats.at(-1))?.commit ?? 0;
339
- }
340
-
341
- /**
342
- * The intro beat of a commit, clamped: before the first is 0, after the last is the end.
343
- * @param {readonly Beat[]} beats
344
- * @param {number} commit
345
- */
346
- export function introOf(beats, commit) {
347
- if (commit < 0) return 0;
348
- const at = beats.findIndex((b) => b.kind === "intro" && b.commit === commit);
349
- return at === -1 ? beats.length : at;
350
- }
351
-
352
- /**
353
- * How each change line of a commit looks at a step position. Lines are hidden until their chunk
354
- * shows them; removals then stay as struck lines. Chunks not yet shown, the current one during
355
- * its narration included, report a placeholder at their first line with their line count.
356
- * current is the chunk beat on screen, or null during the intro and outro.
357
- * @param {readonly Beat[]} beats
358
- * @param {readonly Step[]} steps
359
- * @param {number} position
360
- * @param {number} commit
361
- * @returns {{ states: Map<string, LineState>, upcoming: Map<string, number>, current: number | null }}
362
- */
363
- export function lineStates(beats, steps, position, commit) {
364
- /** @type {Map<string, LineState>} */
365
- const states = new Map();
366
- /** @type {Map<string, number>} */
367
- const upcoming = new Map();
368
- const now = beatAt(steps, position);
369
- beats.forEach((beat, i) => {
370
- if (beat.kind !== "chunk" || beat.commit !== commit || beat.lines.length === 0) return;
371
- const show = steps.findIndex((s) => s.kind === "show" && s.beat === i);
372
- const shown = position >= steps.length || (show !== -1 && show <= position);
373
- const first = beat.lines[0];
374
- if (!shown && first !== undefined) upcoming.set(lineKey(beat.unit, first), beat.lines.length);
375
- const removals = new Set(beat.removals);
376
- for (const line of beat.lines) {
377
- const look = !shown ? "hidden" : removals.has(line) ? "struck" : "shown";
378
- states.set(lineKey(beat.unit, line), look);
379
- }
380
- });
381
- const beat = beats[now];
382
- const current = beat?.kind === "chunk" && beat.commit === commit ? now : null;
383
- return { states, upcoming, current };
384
- }
385
-
386
- /**
387
- * How a hunk reads in one commit. The commit's own lines are the change. Lines earlier commits
388
- * added are now part of the file and read as context; lines they removed are gone. Lines later
389
- * commits remove are still there as context, and lines later commits add fold into one row per
390
- * run and owner.
391
- * @param {readonly HunkLine[]} body
392
- * @param {readonly (number | undefined)[]} owners owning commit per change index
393
- * @param {number} commit
394
- * @returns {({ kind: "line", line: number, mine: boolean } | { kind: "fold", lines: number[], commit: number })[]}
395
- */
396
- export function rowsOf(body, owners, commit) {
397
- /** @type {ReturnType<typeof rowsOf>} */
398
- const rows = [];
399
- body.forEach((line, i) => {
400
- const owner = line.op === " " ? undefined : owners[line.change];
401
- if (line.op === " " || owner === undefined || owner === commit) {
402
- rows.push({ kind: "line", line: i, mine: owner === commit });
403
- return;
404
- }
405
- const later = owner > commit;
406
- if (line.op === "-" ? later : !later) {
407
- rows.push({ kind: "line", line: i, mine: false });
408
- return;
409
- }
410
- if (line.op === "-") return;
411
- const last = rows.at(-1);
412
- if (last?.kind === "fold" && last.commit === owner) last.lines.push(i);
413
- else rows.push({ kind: "fold", lines: [i], commit: owner });
414
- });
415
- return rows;
416
- }
417
-
418
- /**
419
- * The diagram beside the code: while a chunk of a unit with a diagram plays, that unit's; else
420
- * the commit's; while browsing a commit without one, its first unit's. null when none applies.
421
- * @param {Commit | undefined} commit
422
- * @param {Beat | undefined} beat the beat on screen, undefined while browsing
423
- * @returns {{ source: string, unit: string | null } | null}
424
- */
425
- export function diagramAt(commit, beat) {
426
- if (commit === undefined) return null;
427
- const refs = commit.refs.filter((r) => present(r.diagram) !== null);
428
- const playing = beat?.kind === "chunk" ? refs.find((r) => r.unit === beat.unit) : undefined;
429
- const own = present(commit.diagram);
430
- const ref = playing ?? (own === null && beat === undefined ? refs[0] : undefined);
431
- if (ref?.diagram !== undefined) return { source: ref.diagram, unit: ref.unit };
432
- return own === null ? null : { source: own, unit: null };
433
- }
434
-
435
- /** @param {readonly Commit[]} commits */
436
- export function hasDiagrams(commits) {
437
- return commits.some(
438
- (c) => present(c.diagram) !== null || c.refs.some((r) => present(r.diagram) !== null),
439
- );
440
- }
441
-
442
- /**
443
- * File line numbers of every hunk line, 1-based: old in the base file, new in the branch tip. A
444
- * removed line has no new number and an added line no old one. A hunk's tip start is its base
445
- * start shifted by the lines earlier hunks of the same file add or remove.
446
- * @param {readonly Unit[]} units
447
- * @returns {Map<string, { old: number | null, new: number | null }[]>}
448
- */
449
- export function lineNumbers(units) {
450
- /** @type {Map<string, { old: number | null, new: number | null }[]>} */
451
- const numbers = new Map();
452
- const hunks = units.flatMap((u) => (u.kind === "hunk" ? [u] : []));
453
- const sorted = [...hunks].sort((a, b) => (a.oldStart ?? 0) - (b.oldStart ?? 0));
454
- /** @type {Map<string, number>} */
455
- const shift = new Map();
456
- for (const hunk of sorted) {
457
- const start = hunk.oldStart ?? 0;
458
- let oldLine = start + 1;
459
- let newLine = start + 1 + (shift.get(hunk.path) ?? 0);
460
- numbers.set(
461
- hunk.id,
462
- hunk.body.map((line) => {
463
- if (line.op === "-") return { old: oldLine++, new: null };
464
- if (line.op === "+") return { old: null, new: newLine++ };
465
- return { old: oldLine++, new: newLine++ };
466
- }),
467
- );
468
- const delta = hunk.body.reduce((d, l) => d + (l.op === "+" ? 1 : l.op === "-" ? -1 : 0), 0);
469
- shift.set(hunk.path, (shift.get(hunk.path) ?? 0) + delta);
470
- }
471
- return numbers;
472
- }
473
-
474
- /**
475
- * How a comment names the lines it is on: tip line numbers, with removed lines by their old
476
- * number, runs collapsed. "line 4", "lines 4-5", "lines 4-5, old 3".
477
- * @param {readonly HunkLine[]} body
478
- * @param {readonly { old: number | null, new: number | null }[]} numbers
479
- * @param {readonly number[]} changes
480
- */
481
- export function describeLines(body, numbers, changes) {
482
- const picked = new Set(changes);
483
- /** @type {{ side: "new" | "old", n: number }[]} */
484
- const marks = [];
485
- body.forEach((line, i) => {
486
- if (line.op === " " || !picked.has(line.change)) return;
487
- const side = line.op === "+" ? "new" : "old";
488
- const n = numbers[i]?.[side];
489
- if (n !== null && n !== undefined) marks.push({ side, n });
490
- });
491
- /** @param {"new" | "old"} side */
492
- const ranges = (side) => {
493
- const ns = marks.filter((m) => m.side === side).map((m) => m.n);
494
- /** @type {string[]} */
495
- const parts = [];
496
- ns.forEach((n, i) => {
497
- if (i > 0 && n === (ns[i - 1] ?? 0) + 1) {
498
- const last = parts.pop() ?? "";
499
- parts.push(`${last.split("-")[0]}-${n}`);
500
- } else parts.push(String(n));
501
- });
502
- return parts;
503
- };
504
- const added = ranges("new");
505
- const removed = ranges("old");
506
- const many = marks.length > 1;
507
- const text = [
508
- ...(added.length > 0 ? [`${many ? "lines" : "line"} ${added.join(", ")}`] : []),
509
- ...(removed.length > 0
510
- ? [`${added.length > 0 ? "old" : many ? "old lines" : "old line"} ${removed.join(", ")}`]
511
- : []),
512
- ];
513
- return text.join(", ");
514
- }
515
-
516
- /**
517
- * One progress segment per commit, weighted by its chunk count, filled by the share of its
518
- * beats already behind the current one.
519
- * @param {readonly Beat[]} beats
520
- * @param {number} beat
521
- * @returns {{ commit: number, weight: number, filled: number }[]}
522
- */
523
- export function progress(beats, beat) {
524
- /** @type {Map<number, { chunks: number, total: number, behind: number }>} */
525
- const tally = new Map();
526
- beats.forEach((b, i) => {
527
- const t = tally.get(b.commit) ?? { chunks: 0, total: 0, behind: 0 };
528
- tally.set(b.commit, {
529
- chunks: t.chunks + (b.kind === "chunk" ? 1 : 0),
530
- total: t.total + 1,
531
- behind: t.behind + (i < beat ? 1 : 0),
532
- });
533
- });
534
- return [...tally].map(([commit, t]) => ({
535
- commit,
536
- weight: Math.max(1, t.chunks),
537
- filled: t.behind / t.total,
538
- }));
539
- }
540
-
541
- /**
542
- * Which chunk of its commit a beat is, 1-based, or null for an intro, outro or the end.
543
- * @param {readonly Beat[]} beats
544
- * @param {number} beat
545
- * @returns {{ index: number, count: number } | null}
546
- */
547
- export function chunkOf(beats, beat) {
548
- const here = beats[beat];
549
- if (here?.kind !== "chunk") return null;
550
- const mine = beats.flatMap((b, i) => (b.kind === "chunk" && b.commit === here.commit ? [i] : []));
551
- return { index: mine.indexOf(beat) + 1, count: mine.length };
552
- }
553
-
554
- /**
555
- * A speed from storage or a select, snapped to the offered steps.
556
- * @param {unknown} value
557
- */
558
- export function parseSpeed(value) {
559
- const n = Number(value);
560
- return SPEEDS.includes(n) ? n : 1;
561
- }
562
-
563
- /**
564
- * The voice to preselect: the saved one if the browser still has it, else the browser's
565
- * default, else the first voice in the reader's language, else none.
566
- * @param {readonly VoiceInfo[]} voices
567
- * @param {string | null} saved
568
- * @param {string} language
569
- * @returns {string | null}
570
- */
571
- export function pickVoice(voices, saved, language) {
572
- if (saved !== null && voices.some((v) => v.name === saved)) return saved;
573
- const prefix = language.split("-")[0] ?? "";
574
- const match =
575
- voices.find((v) => v.default && v.lang.startsWith(prefix)) ??
576
- voices.find((v) => v.lang === language) ??
577
- voices.find((v) => v.lang.startsWith(prefix));
578
- return match?.name ?? null;
579
- }