SLOPSHOPPER

code-typing-dojo

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

newpanecommandtoastmodel
★ 1v0.1.0MITupdated 2026-10-09oberonlai/claude-code-typing-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-typing-dojo
│ ┃ 打字道場 ✕ › fix the failing auth test and add an audit log call │ ┃ ⌨ 程式碼打字道場 │ ┃ 這個專案裡找不到適合練習的程式碼檔案。 ⏺ Read(src/auth.ts) │ ┃ n: 下一段 ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /typing │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 打字道場
⌨ 程式碼打字道場 這個專案裡找不到適合練習的程式碼檔案。 n: 下一段
README

code-typing-dojo:程式碼打字道場

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

  • 顯示檔案路徑與行號範圍,以及要打的 3–12 行程式碼
  • 逐字標色:打對是綠色、打錯是紅底、還沒打的是暗色
  • 一行一行練習:在輸入欄打目前這一行,按 Enter 換下一行;打完的行保留對錯顏色
  • 即時統計:WPM(每分鐘字數,以「正確字元 ÷ 5」計,從第一個按鍵開始計時)、正確率、進度條
  • 全部打完會顯示成績(速度、正確率、用時)
  • 附上一到三句繁體中文說明,告訴你這段程式碼在做什麼(初學者看得懂的說法)

截圖

Claude Code 2.1.295 全螢幕介面,打字道場面板在右側,正在打第 6 行,打錯的字元以紅底標出

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

打字道場面板特寫

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

練習完成後的成績摘要

全部打完後的成績摘要:速度、正確率、用時與正確字元數。

以上截圖是在本專案的 sample-project/ 裡,透過 tmux 執行 Claude Code 拍攝。第一張圖顯示的 585 WPM 是因為文字是用程式送進終端機的,並非真人打字速度。

需求

  • Claude Code 2.1.287 以上(已在 2.1.295 測試)
  • 想讓它出現在右側側欄:需使用全螢幕(fullscreen)介面,且終端機夠寬
  • 啟動時自動開啟需要至少 144 欄寬(你自己開過一次之後,110 欄即可)
  • 終端機較窄時,面板會改成出現在提示列上方的框框裡;啟動時若沒有自動開啟,會跳出提示「打字練習已就緒,輸入 /typing 開啟」,輸入 /typing 就能在任何寬度開啟
  • 可在終端機與 Claude Desktop 的 Code 分頁使用;claude -p 與 VS Code 擴充功能的聊天面板不會畫出面板

安裝

方法一:單次載入(開發或試用)

cd 你的專案
claude --plugin-dir /path/to/claude-code-typing-mod

方法二:當作本機 marketplace 安裝

這個資料夾本身就附有 .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 移過去)或練習完成、輸入欄消失後才有作用。
  • 每行開頭的縮排會照樣顯示,但不用打,從第一個非空白字元開始打即可。
  • 正確率的分母是你打過的字元數;按 Enter 時少打的字元也會算成錯誤。

片段怎麼挑

  • 從 session 的工作目錄開始用 $.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 的檔案,以及看起來是二進位或壓縮過的內容
  • 優先從函式、類別等定義開始,取 3–12 行連續程式碼;Tab 轉成兩個空白、去掉行尾空白、整段去掉共同縮排;偏好每行 60 字以內,超過 80 字的片段不選

說明怎麼產生、怎麼快取

  • 說明由 $.model.complete 呼叫小型快速模型(haiku)產生,會使用你自己的 Claude 方案額度或 API key
  • 結果存在這個 mod 專屬的 $.store(位於 ~/.claude/plugins/store/),以「檔案路徑 + 片段內容雜湊」當 key,同一段程式碼不會重複呼叫模型;所有 session 共用這份快取
  • 等待時顯示「說明產生中…」;如果模型呼叫失敗或被拒,改顯示簡單的推測說明(檔名、行號,以及偵測到的函式或類別名稱)
  • 所有讀檔、模型呼叫與 store 操作都有錯誤處理,失敗不會讓 session 當掉

限制

  • 面板需要支援文字輸入的介面;Claude 行動版 App 只會顯示片段,無法練習
  • 輸入欄是單行的,所以採「一行一行」練習,無法一次打整段
  • 片段挑選是啟發式的(以正規表示式辨認定義),偶爾會挑到不那麼有代表性的程式碼,按 n 換一段即可
  • 一次挑選最多讀 8 個隨機檔案;超大型專案只會走訪前面一部分資料夾
  • 練習進度存在模組變數裡,mod 重新載入(例如開發時存檔)就會重來;說明快取則會保留
  • mods 介面仍屬早期功能,未來版本的 Claude Code 可能改動 API

專案結構

.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 測試。

Source 2 files
hooks/register.ts 296 lines
1import 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}
296
hooks/core.ts 377 lines
1// 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