用目前專案裡的真實程式碼練習打字:側欄顯示片段、逐字標色、WPM 與正確率,並附上繁體中文說明

一個 Claude Code mod(模組化外掛)。每次在專案裡啟動 Claude Code,它會從目前專案的原始碼挑出一小段真實程式碼,放在側欄讓你練打字:

Claude Code 2.1.295 全螢幕介面:面板在右側側欄,正打到一半,少打一個 l 造成的錯字以紅底標出。

面板特寫:檔案路徑與行號、逐字標色、WPM/正確率/進度,以及程式碼說明。

全部打完後的成績摘要:速度、正確率、用時與正確字元數。
以上截圖是在本專案的
sample-project/裡,透過 tmux 執行 Claude Code 拍攝。第一張圖顯示的 585 WPM 是因為文字是用程式送進終端機的,並非真人打字速度。
/typing 就能在任何寬度開啟claude -p 與 VS Code 擴充功能的聊天面板不會畫出面板cd 你的專案
claude --plugin-dir /path/to/claude-code-typing-mod
這個資料夾本身就附有 .claude-plugin/marketplace.json,可以直接加成 marketplace 再安裝:
claude plugin marketplace add /path/to/claude-code-typing-mod
claude plugin install code-typing-dojo@code-typing-dojo-local
如果安裝時已有開著的 Claude Code,請在該 session 輸入 /reload-plugins。
想先確認這個 mod 會做哪些事,可以執行:
claude plugin validate /path/to/claude-code-typing-mod
輸出中的 hooks: 與 calls: 兩行會列出它掛的事件與使用的 API(讀取檔案、呼叫模型、寫入 store 等)。
| 操作 | 說明 | | :- | :- | | /typing | 開啟(或切換到)打字道場面板,並把鍵盤焦點交給它 | | /typing-next | 換一段新的程式碼 | | 直接打字 | 面板有焦點時,輸入欄會自動取得焦點,打字即時標色 | | Enter | 送出目前這一行,換到下一行 | | Tab | 在輸入欄與按鈕之間移動 | | n | 下一段(焦點在按鈕上,或練習完成後) | | r | 重新開始同一段(焦點在按鈕上,或練習完成後) | | Esc | 把鍵盤焦點還給 Claude 的提示列(面板不會關閉) | | Ctrl+X 再按 Tab | 從提示列把焦點切回面板(也可以直接點面板) | | Ctrl+X 再按 X | 關閉面板 | | Ctrl+X 再按方向鍵 | 調整面板大小(← 或 ↑ 加大,→ 或 ↓ 縮小) |
小提醒:
n、r 快捷鍵只在焦點位於按鈕(按 Tab 移過去)或練習完成、輸入欄消失後才有作用。$.fs.list 逐層走訪(最多 6 層、約 120 個資料夾)node_modules、.git、dist、build、vendor、.claude 等資料夾與其他隱藏資料夾.js .ts .tsx .jsx .py .php .go .rs .rb .java .css .sh 等),跳過 lockfile、.min.js、.d.ts、超過 200 KB 的檔案,以及看起來是二進位或壓縮過的內容$.model.complete 呼叫小型快速模型(haiku)產生,會使用你自己的 Claude 方案額度或 API key$.store(位於 ~/.claude/plugins/store/),以「檔案路徑 + 片段內容雜湊」當 key,同一段程式碼不會重複呼叫模型;所有 session 共用這份快取n 換一段即可.claude-plugin/plugin.json 外掛 manifest(名稱 code-typing-dojo)
.claude-plugin/marketplace.json 本機 marketplace,用來 claude plugin install
hooks/hooks.json 指向 hooks 模組
hooks/register.ts hooks 模組:指令、面板繪製、走訪檔案、模型說明
hooks/core.ts 純邏輯(不使用 $):挑片段、計分、WPM、標色區段
tests/core.test.ts 純邏輯的單元測試
tests/mod.test.ts 以 claude-code/testing 模擬 fs、模型、store 的整合測試
sample-project/ 手動測試用的小專案(JS 與 Python)
claude plugin validate . # 靜態檢查 manifest 與 hooks 模組
claude plugin test # 執行 tests/*.test.ts
tsconfig.json 會 extends .claude-plugin/types/tsconfig.json。這個型別資料夾是 Claude Code 載入 mod 時自動產生的,所以沒有納入版本控制。剛 clone 下來時請先用 claude --plugin-dir . 載入一次,編輯器和 tsc 才找得到型別。claude plugin test 不需要它。
手動試用:
cd sample-project
claude --plugin-dir ..
在 sample-project 裡也可以用非互動模式確認指令能用:
claude -p "/typing-next" --plugin-dir ..
# code-typing-dojo: 已換新片段:src/cart.js:2-5
已在 Claude Code 2.1.295 測試。
hooks/register.ts 296 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 applyInput,
5 cleanExplanation,
6 explanationKey,
7 explanationPrompt,
8 fallbackExplanation,
9 indentOf,
10 isCodeFile,
11 isFinished,
12 isSkippedDir,
13 pickSnippet,
14 progressBar,
15 relativePath,
16 segments,
17 shuffle,
18 startPractice,
19 stats,
20 submitLine,
21 typedPart,
22} from './core.ts'
23import type { Practice, Segment, Snippet } from './core.ts'
24
25const PANE = 'code-typing-dojo'
26const TITLE = '打字道場'
27// The model that writes explanations: small and fast
28const MODEL = 'haiku'
29// How much of the project one pick may walk
30const MAX_DIRS = 120
31const MAX_DEPTH = 6
32const MAX_FILES = 400
33const FILES_TO_TRY = 8
34
35type Explanation = { status: 'loading' | 'ready' | 'fallback'; text: string }
36
37// What the pane shows, shared by the hooks below
38let root = ''
39let snippet: Snippet | null = null
40let practice: Practice | null = null
41let explanation: Explanation | null = null
42let status: 'idle' | 'loading' | 'empty' | 'ready' = 'idle'
43// Each pick gets a number, so a slow pick or explanation can't overwrite a newer one
44let generation = 0
45
46/** Lists the code files under the root, breadth first, skipping vendored and built directories. */
47async function listCodeFiles($: EngineInterface, dir: string): Promise<string[]> {
48 const files: string[] = []
49 const queue: Array<{ path: string; depth: number }> = [{ path: dir, depth: 0 }]
50 let dirs = 0
51 while (queue.length > 0 && dirs < MAX_DIRS && files.length < MAX_FILES) {
52 const { path, depth } = queue.shift()!
53 dirs++
54 let entries
55 try {
56 entries = await $.fs.list(path)
57 } catch {
58 continue
59 }
60 for (const entry of entries) {
61 const full = path.replace(/[\\/]+$/, '') + '/' + entry.name
62 if (entry.kind === 'dir') {
63 if (depth < MAX_DEPTH && !isSkippedDir(entry.name)) queue.push({ path: full, depth: depth + 1 })
64 } else if (entry.kind === 'file' && isCodeFile(entry.name, entry.size)) {
65 files.push(full)
66 }
67 }
68 }
69 return files
70}
71
72/** Reads random code files until one holds a good snippet. */
73async function findSnippet($: EngineInterface, dir: string): Promise<Snippet | null> {
74 const files = shuffle(await listCodeFiles($, dir))
75 for (const file of files.slice(0, FILES_TO_TRY)) {
76 try {
77 const text = await $.fs.read(file)
78 if (typeof text !== 'string') continue
79 const found = pickSnippet(text, relativePath(dir, file))
80 if (found) return found
81 } catch {
82 // An unreadable file: try the next one
83 }
84 }
85 return null
86}
87
88/** Gets the snippet's explanation from the store, or from a model, or falls back to a heuristic. */
89async function explain($: EngineInterface, target: Snippet, gen: number): Promise<void> {
90 const key = explanationKey(target)
91 let text = ''
92 try {
93 const cached = await $.store.get(key)
94 if (typeof cached === 'string' && cached !== '') text = cached
95 } catch {
96 // No store: ask the model
97 }
98 if (text === '') {
99 try {
100 const ask = explanationPrompt(target)
101 const reply = await $.model.complete({
102 model: MODEL,
103 system: ask.system,
104 prompt: ask.prompt,
105 maxTokens: 300,
106 timeoutMs: 30_000,
107 })
108 if (reply.isAnswered) text = cleanExplanation(reply.text)
109 if (text !== '') await $.store.set(key, text)
110 } catch {
111 // A refused model or a failed write: fall back below
112 }
113 }
114 if (gen !== generation) return
115 explanation = text !== '' ? { status: 'ready', text } : { status: 'fallback', text: fallbackExplanation(target) }
116 $.ui.invalidate('ui.render')
117}
118
119/** Picks a new snippet from the project and starts explaining it. */
120async function loadSnippet($: EngineInterface): Promise<void> {
121 const gen = ++generation
122 status = 'loading'
123 $.ui.invalidate('ui.render')
124 let found: Snippet | null = null
125 try {
126 if (root === '') root = await $.session.cwd()
127 found = await findSnippet($, root)
128 } catch {
129 found = null
130 }
131 if (gen !== generation) return
132 snippet = found
133 practice = found ? startPractice(found.lines) : null
134 explanation = found ? { status: 'loading', text: '說明產生中…' } : null
135 status = found ? 'ready' : 'empty'
136 $.ui.invalidate('ui.render')
137 if (found) await explain($, found, gen)
138}
139
140/** Starts the same snippet over. */
141function restart($: EngineInterface): void {
142 if (!snippet) return
143 practice = startPractice(snippet.lines)
144 $.ui.invalidate('ui.render')
145}
146
147const COLORS: Record<Segment['kind'], { color?: string; backgroundColor?: string; dimColor?: boolean }> = {
148 correct: { color: 'green' },
149 wrong: { color: 'white', backgroundColor: 'red' },
150 extra: { color: 'white', backgroundColor: 'red' },
151 pending: { dimColor: true },
152}
153
154export const register: Register = on => {
155 on('session.start', async ($, e, next) => {
156 root = e.cwd
157 try {
158 await $.command.register({ name: 'typing', description: '開啟打字道場(用專案程式碼練打字)', immediate: true })
159 await $.command.register({ name: 'typing-next', description: '打字道場:換一段新的程式碼', immediate: true })
160 } catch {
161 // A name already taken: the pane still works from the other command
162 }
163 // Pick in the background, so the session starts at once
164 void loadSnippet($)
165 if (e.isInteractive) {
166 try {
167 const opened = await $.ui.open({ id: PANE, title: TITLE })
168 if (!opened.isPlaced) $.ui.toast('打字練習已就緒,輸入 /typing 開啟')
169 } catch {
170 $.ui.toast('打字練習已就緒,輸入 /typing 開啟')
171 }
172 }
173 return next(e)
174 })
175
176 on('command.run', { command: 'typing' }, async $ => {
177 if (status === 'idle' || status === 'empty') void loadSnippet($)
178 await $.ui.open({ id: PANE, title: TITLE, focus: true })
179 return {}
180 })
181
182 on('command.run', { command: 'typing-next' }, async $ => {
183 await $.ui.open({ id: PANE, title: TITLE, focus: true })
184 await loadSnippet($)
185 return snippet ? { text: '已換新片段:' + snippet.path + ':' + snippet.startLine + '-' + snippet.endLine } : { text: '找不到適合練習的程式碼檔案。' }
186 })
187
188 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
189 const elements = $.ui.resolve(e)
190 const { Box, Text, Button } = elements
191 // Mobile has no text field, so practice there isn't possible
192 const Input = e.surface !== 'mobile' && 'Input' in elements ? elements.Input : null
193 const now = await $.clock.now()
194
195 const nextButton = (isFocused: boolean) =>
196 Button({
197 key: 'next',
198 label: '下一段',
199 hotkey: 'n',
200 plain: true,
201 ...(isFocused ? { autoFocus: true as const } : {}),
202 onPress: () => loadSnippet($),
203 })
204 const restartButton = Button({ key: 'restart', label: '重新開始', hotkey: 'r', plain: true, onPress: () => restart($) })
205 const header = Text({ bold: true, color: 'claude', children: ['⌨ 程式碼打字道場'] })
206
207 if (status !== 'ready' || !snippet || !practice) {
208 const message =
209 status === 'empty' ? '這個專案裡找不到適合練習的程式碼檔案。' : '正在從專案挑選程式碼…'
210 return Box({
211 flexDirection: 'column',
212 children: [header, Text({ dimColor: true, children: [message] }), nextButton(status === 'empty')],
213 })
214 }
215
216 const p = practice
217 const s = stats(p, now)
218 const done = isFinished(p)
219 const width = String(snippet.endLine).length
220
221 // One row for each line: its number, a marker for the current line, then the colored text
222 const rows = p.lines.map((line, i) => {
223 const isCurrent = i === p.done.length && !done
224 const typed = i < p.done.length ? p.done[i] : isCurrent ? p.current : ''
225 const parts = segments(typedPart(line), typed).map(seg => Text({ ...COLORS[seg.kind], children: [seg.text] }))
226 const gutter = String(snippet!.startLine + i).padStart(width, ' ')
227 return Text({
228 wrap: 'truncate-end',
229 children: [
230 Text({ ...(isCurrent ? { color: 'claude' } : { dimColor: true }), children: [(isCurrent ? '›' : ' ') + gutter + ' '] }),
231 indentOf(line),
232 ...parts,
233 ],
234 })
235 })
236
237 const statLine = Text({
238 children: [
239 'WPM ' + s.wpm + ' · 正確率 ' + s.accuracy + '% · 進度 ' + progressBar(s.linesDone, s.linesTotal) + ' ' + s.linesDone + '/' + s.linesTotal,
240 ],
241 })
242
243 const field = done
244 ? Box({
245 key: 'result',
246 flexDirection: 'column',
247 borderStyle: 'round',
248 paddingX: 1,
249 children: [
250 Text({ bold: true, color: 'success', children: ['🎉 完成!'] }),
251 Text({ children: ['速度 ' + s.wpm + ' WPM,正確率 ' + s.accuracy + '%,用時 ' + s.seconds + ' 秒'] }),
252 Text({ dimColor: true, children: ['正確字元 ' + s.correct + ' / ' + s.typed + '。按 n 換下一段,r 再練一次。'] }),
253 ],
254 })
255 : !Input
256 ? Text({ dimColor: true, children: ['這個介面無法輸入文字,請在終端機或 Claude Desktop 練習。'] })
257 : Input({
258 key: 'typing',
259 label: '輸入',
260 placeholder: typedPart(p.lines[p.done.length]),
261 value: p.current,
262 submitLabel: '下一行',
263 autoFocus: true,
264 onInput: async (value: string) => {
265 const at = await $.clock.now()
266 if (practice) practice = applyInput(practice, value, at)
267 $.ui.invalidate('ui.render')
268 },
269 onSubmit: async (value: string) => {
270 const at = await $.clock.now()
271 if (practice) practice = submitLine(practice, value, at)
272 $.ui.invalidate('ui.render')
273 },
274 })
275
276 return Box({
277 flexDirection: 'column',
278 children: [
279 header,
280 Text({ dimColor: true, wrap: 'truncate-start', children: [snippet.path + ':' + snippet.startLine + '-' + snippet.endLine] }),
281 Text({ children: [' '] }),
282 ...rows,
283 Text({ children: [' '] }),
284 field,
285 statLine,
286 Text({ children: [' '] }),
287 Text({ bold: true, children: ['說明'] }),
288 Text({ dimColor: explanation?.status === 'loading', children: [explanation?.text ?? ''] }),
289 Text({ children: [' '] }),
290 Box({ flexDirection: 'row', columnGap: 3, children: [nextButton(done), restartButton] }),
291 Text({ dimColor: true, children: ['Tab 切到按鈕 · Esc 回到提示列 · Ctrl+X X 關閉'] }),
292 ],
293 })
294 })
295}
296hooks/core.ts 377 lines1// Pure logic for code-typing-dojo: picking snippets, scoring, highlighting.
2// No `$` here, so tests can call every function directly.
3
4export const SKIP_DIRS = new Set([
5 'node_modules', '.git', 'dist', 'build', 'vendor', '.claude', '.claude-plugin',
6 '.next', '.nuxt', 'out', 'coverage', 'target', '__pycache__', '.venv', 'venv',
7 '.idea', '.vscode', '.cache', 'bower_components', '.svn', '.hg', 'tmp',
8])
9
10export const CODE_EXTS = new Set([
11 'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx', 'py', 'php', 'go', 'rs', 'rb',
12 'java', 'kt', 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'css', 'scss', 'sh',
13 'bash', 'zsh', 'lua', 'dart', 'scala', 'ex', 'exs', 'vue', 'svelte', 'sql', 'pl', 'r',
14])
15
16const LOCKFILES = new Set([
17 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'composer.lock', 'Cargo.lock',
18 'Gemfile.lock', 'poetry.lock', 'go.sum', 'bun.lockb', 'Pipfile.lock',
19])
20
21export const MAX_FILE_BYTES = 200_000
22export const MIN_LINES = 3
23export const MAX_LINES = 12
24export const PREFERRED_WIDTH = 60
25export const MAX_WIDTH = 80
26
27export type Snippet = {
28 /** Path relative to the project root, with `/` separators. */
29 path: string
30 /** 1-based line numbers in the file, inclusive. */
31 startLine: number
32 endLine: number
33 /** The lines to type, dedented, tabs as two spaces, no trailing spaces. */
34 lines: string[]
35}
36
37export type Segment = { text: string; kind: 'correct' | 'wrong' | 'pending' | 'extra' }
38
39export type Practice = {
40 lines: string[]
41 /** What was typed for each finished line. */
42 done: string[]
43 /** The text in the field for the current line. */
44 current: string
45 startedAt: number | null
46 finishedAt: number | null
47}
48
49export type Stats = {
50 correct: number
51 typed: number
52 wpm: number
53 accuracy: number
54 linesDone: number
55 linesTotal: number
56 isFinished: boolean
57 seconds: number
58}
59
60/** The extension of a file name, lower case, without the dot. */
61export function extOf(name: string): string {
62 const dot = name.lastIndexOf('.')
63 return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
64}
65
66/** Whether a directory entry should not be walked into. */
67export function isSkippedDir(name: string): boolean {
68 return SKIP_DIRS.has(name) || (name.startsWith('.') && name !== '.')
69}
70
71/** Whether a file looks like source code worth practicing on. */
72export function isCodeFile(name: string, size: number): boolean {
73 if (LOCKFILES.has(name)) return false
74 if (/\.min\.(js|css)$/i.test(name) || /\.(bundle|chunk)\.js$/i.test(name)) return false
75 if (/\.d\.ts$/i.test(name)) return false
76 if (size <= 0 || size > MAX_FILE_BYTES) return false
77 return CODE_EXTS.has(extOf(name))
78}
79
80/** Whether text read from a file is binary or minified, and so not worth typing. */
81export function looksUnusable(text: string): boolean {
82 if (text.includes('\u0000')) return true
83 const lines = text.split('\n')
84 if (lines.some(line => line.length > 400)) return true
85 const avg = text.length / Math.max(1, lines.length)
86 return avg > 160
87}
88
89/** Tabs to two spaces, trailing whitespace trimmed, CR dropped. */
90export function normalizeLine(line: string): string {
91 return line.replace(/\r$/, '').replace(/\t/g, ' ').replace(/\s+$/, '')
92}
93
94const DEFINITION = new RegExp(
95 [
96 String.raw`^\s*(export\s+)?(default\s+)?(async\s+)?function\b`,
97 String.raw`^\s*(export\s+)?(default\s+)?(abstract\s+)?class\b`,
98 String.raw`^\s*(export\s+)?(const|let|var)\s+\w+\s*=\s*(async\s+)?(\([^)]*\)|\w+)\s*=>`,
99 String.raw`^\s*(export\s+)?(interface|type|enum)\s+\w+`,
100 String.raw`^\s*(async\s+)?def\s+\w+`,
101 String.raw`^\s*class\s+\w+`,
102 String.raw`^\s*func\s+`,
103 String.raw`^\s*(pub\s+)?(async\s+)?fn\s+\w+`,
104 String.raw`^\s*(pub\s+)?(struct|impl|trait)\b`,
105 String.raw`^\s*(public|private|protected|static)\s+[\w<>\[\],\s]*\w+\s*\(`,
106 String.raw`^\s*(public\s+|private\s+|protected\s+)?(static\s+)?function\s+\w+`,
107 String.raw`^\s*def\s+\w+`,
108 String.raw`^\s*module\s+\w+`,
109 String.raw`^\s*[\w-]+\s*\(\)\s*\{`,
110 String.raw`^\s*(async\s+)?[a-zA-Z_]\w*\s*\([^)]*\)\s*\{\s*$`,
111 String.raw`^[.#]?[\w-][\w\s.#:>,-]*\{\s*$`,
112 ].join('|'),
113)
114
115/** Whether a line opens a function, class or similar definition. */
116export function isDefinition(line: string): boolean {
117 if (/^\s*(if|for|while|switch|catch|else|return)\b/.test(line)) return false
118 return DEFINITION.test(line)
119}
120
121/** Whether a line has something to type: not blank, not just a brace or a comment. */
122export function isMeaningful(line: string): boolean {
123 const t = line.trim()
124 if (t === '') return false
125 if (/^[{}()\[\];,]+$/.test(t)) return false
126 return !/^(\/\/|#(?!include)|\/\*|\*|--|<!--)/.test(t)
127}
128
129/** Removes the indentation every non-blank line shares. */
130export function dedent(lines: string[]): string[] {
131 const indents = lines.filter(l => l.trim() !== '').map(l => l.length - l.trimStart().length)
132 const cut = indents.length > 0 ? Math.min(...indents) : 0
133 return lines.map(l => l.slice(cut))
134}
135
136type Candidate = { start: number; end: number; lines: string[]; score: number }
137
138/**
139 * The block starting at `start`: up to MAX_LINES consecutive lines, ending
140 * before a blank line. `end` is the index of its last line.
141 */
142function blockAt(all: string[], start: number): { lines: string[]; end: number } {
143 const out: string[] = []
144 for (let i = start; i < all.length && out.length < MAX_LINES; i++) {
145 if (all[i].trim() === '') break
146 out.push(all[i])
147 }
148 // Drop a trailing comment, but keep a closing brace
149 while (out.length > MIN_LINES && !isMeaningful(out[out.length - 1])) {
150 if (/^[}\])]+[;,)]*$/.test(out[out.length - 1].trim())) break
151 out.pop()
152 }
153 return { lines: out, end: start + out.length - 1 }
154}
155
156function scoreBlock(lines: string[], isDef: boolean): number {
157 if (lines.length < MIN_LINES) return -1
158 const widths = lines.map(l => l.length)
159 if (widths.some(w => w > MAX_WIDTH)) return -1
160 const meaningful = lines.filter(isMeaningful).length
161 if (meaningful < 2) return -1
162 let score = meaningful
163 if (isDef) score += 10
164 score -= widths.filter(w => w > PREFERRED_WIDTH).length * 3
165 if (lines.length >= 4 && lines.length <= 10) score += 2
166 return score
167}
168
169/**
170 * Picks a snippet of 3 to 12 consecutive lines from a file's text, preferring
171 * one that starts at a definition and keeps lines short. `random` returns a
172 * number in [0, 1), so tests can make the pick deterministic.
173 */
174export function pickSnippet(text: string, path: string, random: () => number = Math.random): Snippet | null {
175 if (looksUnusable(text)) return null
176 // Keep blank lines as they are so line numbers stay the file's own
177 const all = text.split('\n').map(normalizeLine)
178 const candidates: Candidate[] = []
179 for (let i = 0; i < all.length; i++) {
180 if (!isMeaningful(all[i])) continue
181 const isDef = isDefinition(all[i])
182 // A non-definition start only at the top of a run of code, to keep the list small
183 if (!isDef && i > 0 && all[i - 1].trim() !== '') continue
184 const block = blockAt(all, i)
185 const score = scoreBlock(block.lines, isDef)
186 if (score > 0) candidates.push({ start: i, end: block.end, lines: block.lines, score })
187 }
188 if (candidates.length === 0) return null
189 const best = Math.max(...candidates.map(c => c.score))
190 // Choose among the good ones, not only the very best, so snippets vary
191 const good = candidates.filter(c => c.score >= best - 4)
192 const chosen = good[Math.min(good.length - 1, Math.floor(random() * good.length))]
193 return { path, startLine: chosen.start + 1, endLine: chosen.end + 1, lines: dedent(chosen.lines) }
194}
195
196/** FNV-1a hash of a string, as eight hex digits. */
197export function hashText(text: string): string {
198 let h = 0x811c9dc5
199 for (let i = 0; i < text.length; i++) {
200 h ^= text.charCodeAt(i)
201 h = Math.imul(h, 0x01000193) >>> 0
202 }
203 return h.toString(16).padStart(8, '0')
204}
205
206/** The `$.store` key for a snippet's explanation. */
207export function explanationKey(snippet: Snippet): string {
208 return 'explain:' + snippet.path + ':' + hashText(snippet.lines.join('\n'))
209}
210
211/** The name of the first function, class or method in a snippet, if any. */
212export function detectName(lines: string[]): { kind: string; name: string } | null {
213 const patterns: Array<[RegExp, string]> = [
214 [/\bclass\s+([A-Za-z_$][\w$]*)/, '類別'],
215 [/\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)/, '函式'],
216 [/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/, '函式'],
217 [/\bdef\s+([A-Za-z_]\w*)/, '函式'],
218 [/\bfn\s+([A-Za-z_]\w*)/, '函式'],
219 [/\bfunc\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/, '函式'],
220 [/\b(?:interface|type|enum|struct|trait)\s+([A-Za-z_]\w*)/, '型別'],
221 [/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/, '方法'],
222 ]
223 for (const line of lines) {
224 for (const [re, kind] of patterns) {
225 const m = re.exec(line)
226 if (m) return { kind, name: m[1] }
227 }
228 }
229 return null
230}
231
232/** The explanation shown when the model can't give one. */
233export function fallbackExplanation(snippet: Snippet): string {
234 const file = snippet.path.split('/').pop() || snippet.path
235 const found = detectName(snippet.lines)
236 const where = '這段程式碼來自 ' + file + ' 第 ' + snippet.startLine + '–' + snippet.endLine + ' 行'
237 return found ? where + ',定義了' + found.kind + '「' + found.name + '」。' : where + '。'
238}
239
240/** The prompt that asks a model to explain a snippet. */
241export function explanationPrompt(snippet: Snippet): { system: string; prompt: string } {
242 return {
243 system:
244 '你是親切的程式教學助理。請用繁體中文(台灣用語),以一到三句話、初學者看得懂的方式,說明使用者給的程式碼片段在做什麼。只輸出說明文字,不要使用 Markdown、程式碼區塊或條列。',
245 prompt: '檔案:' + snippet.path + '\n\n' + snippet.lines.join('\n'),
246 }
247}
248
249/** Cleans a model's reply into one short paragraph. */
250export function cleanExplanation(text: string): string {
251 return text
252 .replace(/```[\s\S]*?```/g, '')
253 .replace(/[*_`#>]/g, '')
254 .replace(/\s*\n+\s*/g, ' ')
255 .trim()
256 .slice(0, 400)
257}
258
259/** The part of a line to type: the line without its leading indentation. */
260export function typedPart(line: string): string {
261 return line.trimStart()
262}
263
264/** The indentation of a line, drawn but not typed. */
265export function indentOf(line: string): string {
266 return line.slice(0, line.length - line.trimStart().length)
267}
268
269/**
270 * Splits a target into runs by how the typed text compares: `correct` and
271 * `wrong` for typed characters, `pending` for the rest of the target, and
272 * `extra` for typed characters past its end.
273 */
274export function segments(target: string, typed: string): Segment[] {
275 const out: Segment[] = []
276 const push = (text: string, kind: Segment['kind']) => {
277 const last = out[out.length - 1]
278 if (last && last.kind === kind) last.text += text
279 else out.push({ text, kind })
280 }
281 const n = Math.max(target.length, typed.length)
282 for (let i = 0; i < n; i++) {
283 if (i >= typed.length) push(target[i], 'pending')
284 else if (i >= target.length) push(typed[i], 'extra')
285 else push(target[i], typed[i] === target[i] ? 'correct' : 'wrong')
286 }
287 return out
288}
289
290/** Correct characters, and characters counted, for one line. */
291export function scoreLine(target: string, typed: string, isSubmitted: boolean): { correct: number; total: number } {
292 let correct = 0
293 for (let i = 0; i < typed.length && i < target.length; i++) {
294 if (typed[i] === target[i]) correct++
295 }
296 // A submitted line also counts the characters it left out as misses
297 const total = isSubmitted ? Math.max(typed.length, target.length) : typed.length
298 return { correct, total }
299}
300
301export function startPractice(lines: string[]): Practice {
302 return { lines: [...lines], done: [], current: '', startedAt: null, finishedAt: null }
303}
304
305export function isFinished(p: Practice): boolean {
306 return p.done.length >= p.lines.length
307}
308
309/** The practice after the field's text changed; the clock starts at the first keystroke. */
310export function applyInput(p: Practice, value: string, now: number): Practice {
311 if (isFinished(p)) return p
312 const startedAt = p.startedAt ?? (value.length > 0 ? now : null)
313 return { ...p, current: value, startedAt }
314}
315
316/** The practice after Enter: the line is kept as typed and the next one starts. */
317export function submitLine(p: Practice, value: string, now: number): Practice {
318 if (isFinished(p)) return p
319 const done = [...p.done, value]
320 const startedAt = p.startedAt ?? now
321 const finishedAt = done.length >= p.lines.length ? now : null
322 return { ...p, done, current: '', startedAt, finishedAt }
323}
324
325export function stats(p: Practice, now: number): Stats {
326 let correct = 0
327 let typed = 0
328 p.done.forEach((value, i) => {
329 const s = scoreLine(typedPart(p.lines[i]), value, true)
330 correct += s.correct
331 typed += s.total
332 })
333 if (!isFinished(p)) {
334 const s = scoreLine(typedPart(p.lines[p.done.length]), p.current, false)
335 correct += s.correct
336 typed += s.total
337 }
338 const end = p.finishedAt ?? now
339 const ms = p.startedAt === null ? 0 : Math.max(0, end - p.startedAt)
340 const minutes = ms / 60_000
341 const wpm = minutes > 0 ? Math.round(correct / 5 / minutes) : 0
342 const accuracy = typed > 0 ? Math.round((correct / typed) * 100) : 100
343 return {
344 correct,
345 typed,
346 wpm,
347 accuracy,
348 linesDone: p.done.length,
349 linesTotal: p.lines.length,
350 isFinished: isFinished(p),
351 seconds: Math.round(ms / 1000),
352 }
353}
354
355/** A text progress bar such as `███░░░`. */
356export function progressBar(done: number, total: number, width = 12): string {
357 const filled = total > 0 ? Math.round((done / total) * width) : 0
358 return '█'.repeat(filled) + '░'.repeat(width - filled)
359}
360
361/** A copy of the items in random order. */
362export function shuffle<T>(items: readonly T[], random: () => number = Math.random): T[] {
363 const out = [...items]
364 for (let i = out.length - 1; i > 0; i--) {
365 const j = Math.floor(random() * (i + 1))
366 ;[out[i], out[j]] = [out[j], out[i]]
367 }
368 return out
369}
370
371/** A path relative to the root, with `/` separators. */
372export function relativePath(root: string, full: string): string {
373 const r = root.replace(/[\\/]+$/, '')
374 const rel = full.startsWith(r) ? full.slice(r.length).replace(/^[\\/]+/, '') : full
375 return rel.replace(/\\/g, '/')
376}
377