icoa-cli 2.19.356 → 2.19.357

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 (75) hide show
  1. package/dist/commands/ai4ctf.js +1 -1
  2. package/dist/commands/ctf.js +787 -1
  3. package/dist/commands/ctf4ai-demo.js +1 -1
  4. package/dist/commands/ctf4vla.js +1 -1
  5. package/dist/commands/demo2.js +1502 -1
  6. package/dist/commands/exam.js +1 -1
  7. package/dist/commands/files.js +59 -1
  8. package/dist/commands/lang.js +202 -1
  9. package/dist/commands/log.js +171 -1
  10. package/dist/commands/shell.js +151 -1
  11. package/dist/commands/sim.js +389 -1
  12. package/dist/index.js +355 -1
  13. package/dist/lib/access.js +184 -1
  14. package/dist/lib/aienv.js +205 -1
  15. package/dist/lib/arena-submit.js +21 -1
  16. package/dist/lib/banner.js +31 -1
  17. package/dist/lib/budget.js +6 -1
  18. package/dist/lib/challenge-dir.js +16 -1
  19. package/dist/lib/colors.js +17 -1
  20. package/dist/lib/comms.js +212 -1
  21. package/dist/lib/config.js +93 -1
  22. package/dist/lib/countdown.js +43 -1
  23. package/dist/lib/country-lang.js +39 -1
  24. package/dist/lib/ctfd-client.js +417 -1
  25. package/dist/lib/demo-exam.js +478 -1
  26. package/dist/lib/demo-flags.js +27 -1
  27. package/dist/lib/demo-stats.js +62 -1
  28. package/dist/lib/demo2-progress.js +102 -1
  29. package/dist/lib/docker-probe.d.ts +45 -0
  30. package/dist/lib/docker-probe.js +118 -0
  31. package/dist/lib/editor-spawn.js +53 -1
  32. package/dist/lib/exam-client.js +54 -1
  33. package/dist/lib/exam-sandbox.js +201 -1
  34. package/dist/lib/exam-setup.js +36 -1
  35. package/dist/lib/exam-state.js +273 -1
  36. package/dist/lib/gemini.js +247 -1
  37. package/dist/lib/i18n.js +302 -1
  38. package/dist/lib/integrity-snapshot.js +88 -1
  39. package/dist/lib/interactive-spawn.js +55 -1
  40. package/dist/lib/ipynb-input.js +65 -1
  41. package/dist/lib/kernel-protocol.js +88 -1
  42. package/dist/lib/kernel.js +146 -2
  43. package/dist/lib/learn-curricula.js +309 -1
  44. package/dist/lib/learn-i18n.js +184 -1
  45. package/dist/lib/learn-input.js +101 -1
  46. package/dist/lib/learn-render.js +863 -1
  47. package/dist/lib/learn-state.js +103 -1
  48. package/dist/lib/log-sync.js +155 -1
  49. package/dist/lib/logger.js +49 -1
  50. package/dist/lib/main-rl.js +7 -1
  51. package/dist/lib/menu-nav.js +105 -1
  52. package/dist/lib/notebook-doc.js +137 -1
  53. package/dist/lib/open-file.js +55 -1
  54. package/dist/lib/paper-upgrade.js +119 -1
  55. package/dist/lib/platform.js +99 -1
  56. package/dist/lib/render-card.js +112 -1
  57. package/dist/lib/repl-asker.js +67 -1
  58. package/dist/lib/sample-runner.js +227 -1
  59. package/dist/lib/sandbox.d.ts +7 -1
  60. package/dist/lib/sandbox.js +144 -1
  61. package/dist/lib/shell-split.js +69 -1
  62. package/dist/lib/sim-cooldown.js +75 -1
  63. package/dist/lib/theme.js +119 -1
  64. package/dist/lib/token-format.js +74 -1
  65. package/dist/lib/tool-man.js +418 -1
  66. package/dist/lib/toolset-hash.js +48 -1
  67. package/dist/lib/translation.js +80 -1
  68. package/dist/lib/translations-fetcher.js +95 -1
  69. package/dist/lib/ui.js +99 -1
  70. package/dist/lib/update-check.js +114 -1
  71. package/dist/lib/version.js +24 -1
  72. package/dist/postinstall.js +48 -1
  73. package/dist/repl.js +2391 -1
  74. package/dist/types/index.js +63 -1
  75. package/package.json +1 -1
@@ -1 +1,863 @@
1
- import chalk from"chalk";import o from"string-width";import{localized as e}from"./learn-curricula.js";import{t as n}from"./learn-i18n.js";import{getConfig as l}from"./config.js";import{icoaBannerLines as t}from"./banner.js";import{renderCharts as r,detectCaps as c}from"./render-card.js";function s(){return(l().language||"en").toLowerCase()}const a=64;function g(o="─"){return o.repeat(66)}function i(o,e,n=20){const l=e>0?o/e:0,t=Math.floor(l*n),r=n-t;return`${chalk.green("█".repeat(t))+chalk.gray("░".repeat(r))} ${(100*l).toFixed(1)}%`}function d(o){return o>=30?chalk.red("🔥🔥🔥 "+o+" days"):o>=7?chalk.yellow("🔥 "+o+" days"):o>=1?chalk.gray("· "+o+" day"+(o>1?"s":"")):chalk.gray("—")}export function renderWelcome(o,e,l){const t=s(),r=e.cardsCompleted.length,c=o.totalCards,a=c-r,y=o.modules.find(o=>e.currentCard>=o.cardRange[0]&&e.currentCard<=o.cardRange[1]),u=/demo/i.test(o.id);if(console.log(),console.log(chalk.cyan(" ╭"+g("═"))),console.log(chalk.cyan(" ║")),console.log(chalk.cyan(" ║ ")+chalk.bold.white(" "+(o.name?.trim()||n("academy_title",t)))),console.log(chalk.cyan(" ║")),l)console.log(chalk.cyan(" ║ ")+chalk.white(" "+n(u?"welcome_new_demo":"welcome_new",t)));else{const o=new Date(e.lastSeenAt),l=Math.floor((Date.now()-o.getTime())/36e5),r=l<1?n("just_now",t):l<24?`${l}${n("ago_hours",t)}`:`${Math.floor(l/24)}${n("ago_days",t)}`;console.log(chalk.cyan(" ║ ")+chalk.white(` ${n("welcome_back",t)} ${r}`))}console.log(chalk.cyan(" ║"));const h=y?`${y.number}. ${y.name}`:"—";console.log(chalk.cyan(" ║ ")+" "+chalk.gray(n("module",t).padEnd(11))+chalk.white(h)),console.log(chalk.cyan(" ║ ")+" "+chalk.gray(n("progress",t).padEnd(11))+i(r,c)+chalk.gray(` (${r}/${c})`)),console.log(chalk.cyan(" ║ ")+" "+chalk.gray(n("streak",t).padEnd(11))+d(e.streakDays)),a>0&&e.currentCard<=c&&console.log(chalk.cyan(" ║ ")+" "+chalk.gray(n("next_card",t).padEnd(11))+chalk.white(`#${e.currentCard} ${n("of",t)} ${c}`)),console.log(chalk.cyan(" ║")),console.log(chalk.cyan(" ╰"+g("═"))),console.log(),console.log(chalk.gray(" ─────────────────────────────────────────────"));const m=0===r?n("continue_start",t):`${n("continue_resume",t)} ${e.currentCard}`;console.log(chalk.bold.green(" continue")+chalk.gray(" "+m)),console.log(chalk.yellow(" status")+chalk.gray(" "+n("status_full",t))),e.bookmarks.length>0&&console.log(chalk.yellow(" bookmarks")+chalk.gray(` ${e.bookmarks.length} ${n("bookmarks_desc",t)}`)),console.log(chalk.gray(" quit")+chalk.gray(" "+n("quit_desc",t))),console.log(chalk.gray(" ─────────────────────────────────────────────")),console.log()}function y(o,e){const l=s(),t=o.module,r=e.modules.find(o=>o.number===t),c=r?r.name:n("unknown_module",l),g=n("module",l).replace(":","").trim(),i=`${n("card",l)} ${o.number} / ${e.totalCards} · ${g} ${t} · ${c}`;console.log(),console.log(chalk.cyan(" ╭─ ")+chalk.bold.white(i)+" "+chalk.cyan("─".repeat(Math.max(0,a-i.length-4))))}function u(){console.log(chalk.cyan(" ╰"+g())),console.log()}function h(o){console.log(chalk.cyan(" │ ")+chalk.white(o))}function m(){console.log(chalk.cyan(" │"))}const f=/[┌┐└┘├┤┬┴┼─│╔╗╚╝═║╠╣╦╩╬▓▒░█]/;function b(o){return/^(?: {4}|\t)/.test(o)||f.test(o)||/ {3,}/.test(o)}function p(e,n){const l=[];let t="",r=0,c=!1;for(const s of function(e){const n=[],l=e=>{let l="";const t=()=>{l&&(n.push({t:"atom",s:l}),l="")};for(const r of e)" "===r?(t(),n.push({t:"space"})):2===o(r)?(t(),n.push({t:"wide",s:r})):l+=r;t()},t=/`[^`]*`/g;let r,c=0;for(;null!==(r=t.exec(e));)l(e.slice(c,r.index)),n.push({t:"code",s:r[0]}),c=r.index+r[0].length;return l(e.slice(c)),n}(e)){if("space"===s.t){""!==t&&(c=!0);continue}const e=s.s,a=o("code"===s.t?e.replace(/`/g,""):e),g=c?1:0,i="atom"===s.t&&e.length<=2&&/^[.,;:!?)\]}…'"]+$/.test(e);""!==t&&!i&&r+g+a>n?(l.push(t),t=e,r=a,c=!1):(c&&(t+=" ",r+=1,c=!1),t+=e,r+=a)}return t&&l.push(t),l}const w=/\b(?:Common bug|Common mistake|Common error|Pitfall|Gotcha|Footgun)\b[::]?|\b(?:Warning|Caution)\b[::]|(?:常见错误|常见陷阱|常见误区|陷阱|注意|警告|误区)[::]/gi;function _(o,e){const n=e?chalk.bold.white:chalk.white;let l,t="",r=0;for(w.lastIndex=0;null!==(l=w.exec(o));)l.index>r&&(t+=n(o.slice(r,l.index))),t+=chalk.bold.yellow(l[0]),r=l.index+l[0].length;return r<o.length&&(t+=n(o.slice(r))),t}function $(o,e=!1){let n="",l=0;const t=/`([^`]*)`/g;let r;for(;null!==(r=t.exec(o));)r.index>l&&(n+=_(o.slice(l,r.index),e)),n+=chalk.cyan(r[1]),l=r.index+r[0].length;return l<o.length&&(n+=_(o.slice(l),e)),n}function k(o,e=!1){let n=!1;for(const l of o.split("\n"))if(/^\s*```/.test(l))n=!n;else if(n)console.log(chalk.cyan(" │ ")+chalk.cyan(` ${l}`));else if(""!==l)if(b(l))console.log(chalk.cyan(" │ ")+chalk.white(l));else for(const o of p(l,60))console.log(chalk.cyan(" │ ")+` ${$(o,e)}`);else m()}function C(o,e){const n=[];for(const l of o.split("\n")){if(""===l){n.push("");continue}if(b(l)){n.push(l);continue}let o="";for(const t of l.split(" "))(o+" "+t).trim().length>e?(n.push(o.trim()),o=t):o=(o+" "+t).trim();o&&n.push(o)}return n}export function renderKnowledgeCard(l,t){const g=s(),i=e(l,g);y(i,t),m();const d=p(i.title,60);for(const o of d)h(chalk.bold.yellow(o));h(chalk.gray("─".repeat(Math.min(Math.max(...d.map(e=>o(e))),a))));const f=!!(l.chart_ids&&l.chart_ids.length>0);if(f&&r(l.chart_ids.slice(0,1),t.charts,c()),i.key_point&&""!==i.key_point.trim()?(f||m(),function(o,e,n){const l=s().startsWith("zh"),t=c().cols,r="-".repeat(Math.min(a,Math.max(24,t-6))),g=l?"要点":"KEY POINT",i=l?`第 ${e} / ${n} 张`:`Card ${e} / ${n}`;console.log(chalk.cyan(" │ ")+chalk.green(r)),console.log(chalk.cyan(" │ ")+chalk.green(`${g} · ${i}`));for(const e of p(o,60))console.log(chalk.cyan(" │ ")+chalk.bold.green(" "+e));console.log(chalk.cyan(" │ ")+chalk.green(r)),m()}(i.key_point,i.number,t.totalCards)):f||m(),i.body.filter(o=>null!=o&&""!==o.trim()).forEach((o,e)=>{const n=0===e?o.match(/^([\s\S]*?(?:[.!?](?=\s|$)|[。!?]))(\s[\s\S]*)?$/):null;if(n&&n[1].trim().length<=180){k(n[1],!0);const o=n[2]?.trimStart();o&&k(o,!1)}else k(o,!1);m()}),i.icoaConnection){h(chalk.magenta(" "+n("icoa_connection",g))),h(chalk.gray(" "+"─".repeat(20)));for(const o of p(i.icoaConnection,60))h(" "+chalk.magenta(o));m()}if(l.check){const o=g.startsWith("zh"),e=l._zh?.checkStatement??l._zh?.check?.statement,n=o?e??l.check.statement:l.check.statement;h(chalk.cyan(" 🤔 "+(o?"请回答 (输 y 或 n):":"Quick comprehension check (type y or n):"))),h(chalk.gray(" "+"─".repeat(20)));for(const o of p(n,60))h(" "+chalk.white(o));m()}u(),function(o,e){const n=s().startsWith("zh"),l=c().cols,t="-".repeat(Math.min(a,Math.max(24,l-6))),r=chalk.gray(" "),g=[];o.check?(g.push(chalk.bold.green("[y]")+chalk.gray(n?" 是":" yes")),g.push(chalk.bold.green("[n]")+chalk.gray(n?" 否":" no"))):g.push(chalk.bold.green("[ok]")+chalk.gray(n?" 继续":" next")),g.push(chalk.bold.yellow("[e]")+chalk.gray(n?" 报告问题":" report")),g.push(chalk.bold.cyan("[b]")+chalk.gray(n?" 上一张":" back")),g.push(chalk.bold.cyan("[card N]")+chalk.gray(n?" 跳转":" jump")),g.push(chalk.gray("[quit]")+chalk.gray(n?" 退出":" quit")),console.log(chalk.gray(" "+t)),console.log(chalk.gray(" "+(n?`第 ${o.number} / ${e.totalCards} 张`:`Card ${o.number} / ${e.totalCards}`))),console.log(" "+g.join(r)),console.log(chalk.gray(" "+t)),console.log(chalk.gray(" "+(n?"e = 报告本卡错误,不算作答;提交后回到本题继续":"e = report a problem with this card (not an answer); you return here after"))),console.log(chalk.gray(" "+(n?"更多: bookmark · status · lang zh/en":"more: bookmark · status · lang zh/en"))),console.log()}(l,t)}export function renderCheckFeedback(o,e,n,l=!1){const t=s().startsWith("zh");if(console.log(),n)console.log(chalk.green(" ✓ ")+chalk.white(t?"答对了。":"Correct."));else if(console.log(chalk.yellow(" ✗ ")+chalk.white(t?"记下了,继续。":"Noted — moving on.")),l){const e="y"===o.check?.answer?t?"是":"Yes":t?"否":"No";console.log(chalk.gray(" "+(t?"正确答案: ":"Answer: ")+e+". "+(t?"回看上面那段。":"Re-read the paragraph above.")))}const r=t?o._zh?.solution??o.solution:o.solution;if(r&&r.length>0){console.log(),console.log(chalk.cyan(" 📝 "+(t?"解答:":"Worked solution:")));for(const o of r){for(const e of p(o,60))console.log(chalk.white(" "+e));console.log()}}console.log()}function x(o){return[chalk.white(" "+(o?"编著:Charlie Zhu":"Created & edited by Charlie Zhu")),chalk.gray(" "+(o?"ICOA 2026 创始人 · 课程总架构师":"Founder & Chief Curriculum Architect · ICOA 2026"))]}export function renderPhaseComplete(o,e,n,l){const r=s().startsWith("zh"),[c,a]=n.cardRange,g=e.checkResults||{};let i=0,d=0;for(let o=c;o<=a;o++){const e=g[String(o)];e&&(i++,e.correct&&d++)}const y="-".repeat(48);console.log();for(const o of t(" "))console.log(o);console.log(),console.log(chalk.bold.yellow(" "+y)),console.log(chalk.bold.yellow(" "+(r?`阶段 ${n.number} 完成`:`Phase ${n.number} complete`))+chalk.gray(" · ")+chalk.white(n.name));const u=i>0&&d===i?chalk.bold.green:chalk.bold.white;console.log(chalk.gray(" "+(r?"理解检查得分: ":"comprehension score: "))+u(`${d}/${i}`)+chalk.gray(0===i?r?" (本阶段未作答 y/n)":" (no y/n answered)":"")),l&&console.log(chalk.gray(" "+(r?"下一阶段: ":"next: "))+chalk.bold.cyan(r?`阶段 ${l.number}`:`Phase ${l.number}`)+chalk.gray(" · ")+chalk.white(l.name)),console.log(chalk.bold.yellow(" "+y));for(const o of x(r))console.log(o)}export function renderCurriculumComplete(o,e){const n=s().startsWith("zh"),l=e.checkResults||{},r="-".repeat(48);let c=0,a=0;console.log();for(const o of t(" "))console.log(o);console.log(),console.log(chalk.bold.green(" "+r)),console.log(chalk.bold.green(" 🏆 "+(n?`${o.name} — 全部完成!`:`${o.name} — complete!`))),console.log(chalk.bold.green(" "+r));for(const e of o.modules){const[o,t]=e.cardRange;let r=0,s=0;for(let e=o;e<=t;e++){const o=l[String(e)];o&&(r++,o.correct&&s++)}c+=r,a+=s;const g=r>0&&s===r?chalk.green:chalk.white;console.log(chalk.gray(" "+(n?`阶段 ${e.number}`:`Phase ${e.number}`).padEnd(8))+g(`${s}/${r}`.padEnd(7))+chalk.gray(e.name))}console.log(chalk.bold.green(" "+r)),console.log(chalk.bold.green(" "+(n?"总分 / overall: ":"Overall: "))+chalk.bold.white(`${a}/${c}`)),console.log(chalk.bold.green(" "+r));for(const o of x(n))console.log(o);console.log()}export function renderMCQCard(o,l){const t=s(),r=e(o,t);y(r,l),m(),h(chalk.bold.yellow("🎯 "+r.title)),h(chalk.gray("─".repeat(Math.min(r.title.length+4,a)))),m();for(const o of C(r.question,60))h(" "+o);m();for(const o of["A","B","C","D"])h(chalk.cyan(` ${o}.`)+" "+chalk.white(r.options[o]));m(),u(),console.log(chalk.gray(" "+(t.startsWith("zh")?"输入 ":"Type "))+chalk.bold.green("A")+chalk.gray(" / ")+chalk.bold.green("B")+chalk.gray(" / ")+chalk.bold.green("C")+chalk.gray(" / ")+chalk.bold.green("D")+chalk.gray(" "+n("type_to_answer",t))),console.log()}export function renderMCQFeedback(o,l,t,r){const c=s(),a=e(o,c);console.log(),t?console.log(" "+chalk.bold.green(n("correct",c)+" ")+chalk.gray(`${n("one_point",c)} · ${l} = ${a.options[l]}`)):console.log(" "+chalk.bold.red(n("not_quite",c)+" ")+chalk.gray(`${n("you_chose",c)} ${l}; ${n("answer_is",c)} ${a.answer}.`)),console.log(),console.log(chalk.gray(" "+n("explanation",c)));for(const o of C(a.explanation,60))console.log(chalk.gray(" "+o));const g=Object.values(r.mcqResults),i=g.filter(o=>o.correct).length;console.log(),console.log(chalk.gray(" "+n("mcq_accuracy_so_far",c)+" ")+chalk.white(`${i}/${g.length}`)),console.log(),t||(console.log(chalk.gray(" "+n("press_ok_continue",c)+" ")+chalk.bold.green("ok")+chalk.gray(" "+n("to_continue",c))),console.log())}export function renderPracticalCard(o,l){const t=s(),r=e(o,t);y(r,l),m(),h(chalk.bold.yellow("🛠 "+r.title)),h(chalk.gray("─".repeat(Math.min(r.title.length+4,a)))),m();for(const o of C(r.task,60))h(" "+o);if(r.starterCode){m(),h(chalk.gray(" "+n("starter_code",t))),m();for(const o of r.starterCode.split("\n"))h(" "+chalk.cyan(o))}m(),u(),console.log(chalk.gray(" "+n("try_in_sandbox",t)+" ")+chalk.bold.cyan("!python3")+chalk.gray(" "+n("drops_into_python",t))),console.log(chalk.gray(" "+n("when_done",t))),console.log(chalk.gray(" ")+chalk.bold.green("done")+chalk.gray(" "+n("done_desc",t))),console.log(chalk.gray(" ")+chalk.bold.yellow("skip")+chalk.gray(" "+n("skip_desc",t))),console.log(chalk.gray(" card N jump to a card by number")),console.log()}export function renderPracticalSuccess(o){const l=s(),t=e(o,l);console.log(),console.log(" "+chalk.bold.green(n("practical_recorded",l))),console.log(),console.log(chalk.gray(" "+n("reference_answer",l)));for(const o of C(t.successHint,60))console.log(chalk.gray(" "+o));console.log(),console.log(chalk.gray(" "+n("press_ok_continue",l)+" ")+chalk.bold.green("ok")+chalk.gray(" "+n("to_continue",l))),console.log()}export function renderSimDemoCard(o,l){const t=s(),r=e(o,t);y(r,l),m(),h(chalk.bold.yellow("🎬 "+r.title)),m();for(const o of C(r.description,60))h(" "+o);m(),h(chalk.gray(" "+n("sim_requires",t))),m(),u(),console.log(chalk.gray(" ")+chalk.bold.cyan("sim")+chalk.gray(" "+n("sim_launch_desc",t))),console.log(chalk.gray(" ")+chalk.bold.green("ok")+chalk.gray(" / ")+chalk.bold.green("next")+chalk.gray(" "+n("continue_to_next",t))),console.log(chalk.gray(" bookmark "+n("bookmark_desc",t))),console.log(chalk.gray(" back "+n("back_desc",t))),console.log(chalk.gray(" card N jump to a card by number")),console.log(chalk.gray(" quit "+n("quit_desc",t))),console.log()}export function renderArenaTaskCard(o,n){const l=s(),t=l.startsWith("zh"),r=e(o,l);y(r,n),m(),h(chalk.bold.cyan("🏟 "+r.title)),h(chalk.gray(" "+r.origin+" · "+r.domain+" · "+r.metric)),m();for(const o of r.statement){for(const e of C(o,60))h(" "+e);m()}h(chalk.bold.green(t?" 核心要点 — baseline → 解法的关键变化":" CORE DELTA — baseline → solution"));for(const o of r.core_delta)for(const e of C("• "+o,58))h(" "+chalk.white(e));m(),h(chalk.gray((t?" 提交格式: ":" Submission: ")+r.submission_format)),r.hints&&h(chalk.gray(t?" 本题有 5 级提示 (a–e),进入 arena 后逐级解锁。":" 5 progressive hints (a–e) unlock inside the arena.")),m(),u(),console.log(chalk.gray(" ")+chalk.bold.cyan("arena "+r.task_id)+chalk.gray(t?" 动手做这道题(下载 / 提示 / 提交 / 榜单)":" open the arena (download / hints / submit / board)")),console.log(chalk.gray(" ")+chalk.bold.green("ok")+chalk.gray(" / ")+chalk.bold.green("next")+chalk.gray(t?" 继续下一张":" continue to next")),console.log(chalk.gray(" back "+(t?"上一张":"previous"))),console.log(chalk.gray(" card N jump to a card by number")),console.log(chalk.gray(" quit "+(t?"退出":"exit"))),console.log()}export function renderComingSoonCard(o,n){const l=s(),t=l.startsWith("zh"),r=e(o,l);y(r,n),m(),h(chalk.bold.yellow("🔜 "+r.title)),m();const c=r.note||(t?"这道竞技场题目正在编写中,敬请期待。":"This arena task is coming soon.");for(const o of C(c,60))h(" "+chalk.gray(o));m(),u(),console.log(chalk.gray(" ")+chalk.bold.green("ok")+chalk.gray(" / ")+chalk.bold.green("next")+chalk.gray(t?" 继续":" continue")),console.log(chalk.gray(" card N jump to a card by number")),console.log(chalk.gray(" quit "+(t?"退出":"exit"))),console.log()}export function renderMilestone(o,l){const t=s(),r=e(o,t),c=o.number===l.totalCards;console.log(),console.log(chalk.bold.yellow(" ╭"+g("═"))),console.log(chalk.bold.yellow(" ║")),console.log(chalk.bold.yellow(" ║ ")+chalk.white(" "+n("milestone_header",t))),console.log(chalk.bold.yellow(" ║"));const a=` ${r.badge} ${r.emoji}`;console.log(chalk.bold.yellow(" ║ ")+chalk.bold.green(a)),console.log(chalk.bold.yellow(" ║ ")+chalk.gray(" ─".repeat(Math.max(1,Math.floor(r.badge.length/2+2))))),console.log(chalk.bold.yellow(" ║")),console.log(chalk.bold.yellow(" ║ ")+" "+chalk.gray(n("in_wild_corresponds",t)));for(const o of C(r.realWorldLevel,60))console.log(chalk.bold.yellow(" ║ ")+" "+chalk.white(o));console.log(chalk.bold.yellow(" ║")),console.log(chalk.bold.yellow(" ║ ")+" "+chalk.gray(n("whats_next",t)));for(const o of C(r.unlockedNext,60))console.log(chalk.bold.yellow(" ║ ")+" "+chalk.white(o));console.log(chalk.bold.yellow(" ║")),console.log(chalk.bold.yellow(" ╰"+g("═"))),console.log(),c?(console.log(chalk.gray(" "+n("demo_complete",t))),console.log(),console.log(chalk.gray(" "+n("unlock_full",t)+" ")+chalk.white(n("curriculum_name",t))+chalk.gray(",")),console.log(chalk.gray(" "+n("contact_team_leader",t)+" ")+chalk.bold.yellow("EA")+chalk.gray(" "+n("learn_token",t))),console.log(chalk.gray(" "+n("or_email",t)+" ")+chalk.cyan("australia@icoa2026.au")+chalk.gray(" "+n("for_partnership",t))),console.log(),console.log(chalk.gray(" "+n("type_quit",t)+" ")+chalk.bold.green("quit")+chalk.gray(" "+n("to_exit",t)+" ")+chalk.bold.green("status")+chalk.gray(" "+n("for_dashboard",t)))):(console.log(chalk.gray(" ")+chalk.bold.green("ok")+chalk.gray(" / ")+chalk.bold.green("next")+chalk.gray(" "+n("continue_to_next",t))),console.log(chalk.gray(" status "+n("status_full",t))),console.log(chalk.gray(" card N jump to a card by number")),console.log(chalk.gray(" quit "+n("quit_desc",t)))),console.log()}export function renderStatus(o,e){const l=s(),t=e.cardsCompleted.length,r=o.totalCards,c=Object.values(e.mcqResults),a=c.filter(o=>o.correct).length;console.log(),console.log(chalk.cyan(" ╭─ ")+chalk.bold.white(n("status_title",l))+" "+chalk.cyan("─".repeat(25))),m(),h(" "+chalk.gray(n("total_progress",l)+" ")+i(t,r)+chalk.gray(` (${t}/${r})`)),h(" "+chalk.gray(n("streak",l)+" ")+d(e.streakDays)+chalk.gray(` (${n("longest",l)} ${e.longestStreak})`)),h(" "+chalk.gray(n("mcq_accuracy",l)+" ")+chalk.white(`${a}/${c.length}`)),h(" "+chalk.gray(n("practicals_done",l)+" ")+chalk.white(`${e.practicalsCompleted.length}`)),h(" "+chalk.gray(n("bookmarked",l)+" ")+chalk.white(`${e.bookmarks.length}`)),m();const g=n("module",l).replace(":","").trim();for(const n of o.modules){const[o,l]=n.cardRange,t=e.cardsCompleted.filter(e=>e>=o&&e<=l).length,r=l-o+1;h(" "+(t===r?chalk.green("✓"):t>0?chalk.yellow("▶"):chalk.gray("□"))+" "+chalk.gray(`${g} ${n.number}: `)+chalk.white(`${t}/${r}`)+chalk.gray(" "+n.name))}if(m(),e.achievements.length>0){h(" "+chalk.gray(n("achievements",l)));for(const o of e.achievements)h(" "+chalk.bold.yellow("★ ")+chalk.white(o))}else h(" "+chalk.gray(n("achievements_none",l)));m(),u()}
1
+ /**
2
+ * Learn-mode rendering helpers.
3
+ * All output uses chalk (theme.ts sets black background — never use bare console.log).
4
+ *
5
+ * Border style: three-sided frame (top / bottom / left). No right border —
6
+ * variable-length lines (especially with ANSI colour codes) can't be padded
7
+ * reliably across terminals, so any right edge breaks alignment. Top and
8
+ * bottom are open-ended horizontal rules; left is a continuous vertical bar.
9
+ *
10
+ * i18n: `localized(card, lang)` from learn-curricula.ts applies `_zh` field
11
+ * overlays on each card. `t(key, lang)` from learn-i18n.ts handles chrome
12
+ * (welcome screen, prompts, dashboard).
13
+ */
14
+ import chalk from 'chalk';
15
+ import stringWidth from 'string-width';
16
+ import { localized } from './learn-curricula.js';
17
+ import { t } from './learn-i18n.js';
18
+ import { getConfig } from './config.js';
19
+ import { icoaBannerLines } from './banner.js';
20
+ import { renderCharts, detectCaps } from './render-card.js';
21
+ function lang() {
22
+ return (getConfig().language || 'en').toLowerCase();
23
+ }
24
+ const W = 64; // content width — drives top/bottom rule length only
25
+ function rule(char = '─') {
26
+ return char.repeat(W + 2);
27
+ }
28
+ function progressBar(done, total, width = 20) {
29
+ const ratio = total > 0 ? done / total : 0;
30
+ const filled = Math.floor(ratio * width);
31
+ const empty = width - filled;
32
+ const bar = chalk.green('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
33
+ const pct = (ratio * 100).toFixed(1);
34
+ return `${bar} ${pct}%`;
35
+ }
36
+ function fireStreak(days) {
37
+ if (days >= 30)
38
+ return chalk.red('🔥🔥🔥 ' + days + ' days');
39
+ if (days >= 7)
40
+ return chalk.yellow('🔥 ' + days + ' days');
41
+ if (days >= 1)
42
+ return chalk.gray('· ' + days + ' day' + (days > 1 ? 's' : ''));
43
+ return chalk.gray('—');
44
+ }
45
+ // ────────────────────────────────────────────────────────────────────────────
46
+ // Welcome screen
47
+ // ────────────────────────────────────────────────────────────────────────────
48
+ export function renderWelcome(curriculum, state, isNewSession) {
49
+ const L = lang();
50
+ const done = state.cardsCompleted.length;
51
+ const total = curriculum.totalCards;
52
+ const remaining = total - done;
53
+ const currentModule = curriculum.modules.find((m) => state.currentCard >= m.cardRange[0] && state.currentCard <= m.cardRange[1]);
54
+ // Title + "free demo" line are derived from the curriculum itself, never
55
+ // hardcoded — a fixed "Embodied AI Security Academy / free demo" banner was
56
+ // wrong for every paid track (ai4ctf/ioai/…). Source of truth = curriculum.name
57
+ // + an id-based demo check. (Same lesson as the exam header-from-truth rule.)
58
+ const isDemo = /demo/i.test(curriculum.id);
59
+ console.log();
60
+ console.log(chalk.cyan(' ╭' + rule('═')));
61
+ console.log(chalk.cyan(' ║'));
62
+ console.log(chalk.cyan(' ║ ') + chalk.bold.white(' ' + (curriculum.name?.trim() || t('academy_title', L))));
63
+ console.log(chalk.cyan(' ║'));
64
+ if (isNewSession) {
65
+ console.log(chalk.cyan(' ║ ') + chalk.white(' ' + t(isDemo ? 'welcome_new_demo' : 'welcome_new', L)));
66
+ }
67
+ else {
68
+ const lastDate = new Date(state.lastSeenAt);
69
+ const hoursAgo = Math.floor((Date.now() - lastDate.getTime()) / 3600000);
70
+ const since = hoursAgo < 1
71
+ ? t('just_now', L)
72
+ : hoursAgo < 24
73
+ ? `${hoursAgo}${t('ago_hours', L)}`
74
+ : `${Math.floor(hoursAgo / 24)}${t('ago_days', L)}`;
75
+ console.log(chalk.cyan(' ║ ') + chalk.white(` ${t('welcome_back', L)} ${since}`));
76
+ }
77
+ console.log(chalk.cyan(' ║'));
78
+ const moduleName = currentModule ? `${currentModule.number}. ${currentModule.name}` : '—';
79
+ console.log(chalk.cyan(' ║ ') + ' ' + chalk.gray(t('module', L).padEnd(11)) + chalk.white(moduleName));
80
+ console.log(chalk.cyan(' ║ ') +
81
+ ' ' +
82
+ chalk.gray(t('progress', L).padEnd(11)) +
83
+ progressBar(done, total) +
84
+ chalk.gray(` (${done}/${total})`));
85
+ console.log(chalk.cyan(' ║ ') + ' ' + chalk.gray(t('streak', L).padEnd(11)) + fireStreak(state.streakDays));
86
+ if (remaining > 0 && state.currentCard <= total) {
87
+ console.log(chalk.cyan(' ║ ') +
88
+ ' ' +
89
+ chalk.gray(t('next_card', L).padEnd(11)) +
90
+ chalk.white(`#${state.currentCard} ${t('of', L)} ${total}`));
91
+ }
92
+ console.log(chalk.cyan(' ║'));
93
+ console.log(chalk.cyan(' ╰' + rule('═')));
94
+ console.log();
95
+ console.log(chalk.gray(' ─────────────────────────────────────────────'));
96
+ const continueLabel = done === 0 ? t('continue_start', L) : `${t('continue_resume', L)} ${state.currentCard}`;
97
+ console.log(chalk.bold.green(' continue') + chalk.gray(' ' + continueLabel));
98
+ console.log(chalk.yellow(' status') + chalk.gray(' ' + t('status_full', L)));
99
+ if (state.bookmarks.length > 0)
100
+ console.log(chalk.yellow(' bookmarks') + chalk.gray(` ${state.bookmarks.length} ${t('bookmarks_desc', L)}`));
101
+ console.log(chalk.gray(' quit') + chalk.gray(' ' + t('quit_desc', L)));
102
+ console.log(chalk.gray(' ─────────────────────────────────────────────'));
103
+ console.log();
104
+ }
105
+ // ────────────────────────────────────────────────────────────────────────────
106
+ // Card rendering — varies by type
107
+ // ────────────────────────────────────────────────────────────────────────────
108
+ function cardHeader(card, curriculum) {
109
+ const L = lang();
110
+ const moduleNum = card.module;
111
+ const module = curriculum.modules.find((m) => m.number === moduleNum);
112
+ const moduleName = module ? module.name : t('unknown_module', L);
113
+ const moduleLabel = t('module', L).replace(':', '').trim();
114
+ const header = `${t('card', L)} ${card.number} / ${curriculum.totalCards} · ${moduleLabel} ${moduleNum} · ${moduleName}`;
115
+ console.log();
116
+ console.log(chalk.cyan(' ╭─ ') + chalk.bold.white(header) + ' ' + chalk.cyan('─'.repeat(Math.max(0, W - header.length - 4))));
117
+ }
118
+ function cardFooter() {
119
+ console.log(chalk.cyan(' ╰' + rule()));
120
+ console.log();
121
+ }
122
+ function bodyLine(text) {
123
+ console.log(chalk.cyan(' │ ') + chalk.white(text));
124
+ }
125
+ function emptyLine() {
126
+ console.log(chalk.cyan(' │'));
127
+ }
128
+ // Box-drawing characters used in ASCII diagrams. Arrows (→ ← ↑ ↓) are
129
+ // excluded — they appear inline in 110+ ctf4eai-360 prose paragraphs.
130
+ const BOX_DRAWING = /[┌┐└┘├┤┬┴┼─│╔╗╚╝═║╠╣╦╩╬▓▒░█]/;
131
+ function isPreformatted(line) {
132
+ return /^(?: {4}|\t)/.test(line) || BOX_DRAWING.test(line) || / {3,}/.test(line);
133
+ }
134
+ function tokenizeForWrap(text) {
135
+ const toks = [];
136
+ const pushText = (str) => {
137
+ let word = '';
138
+ const flush = () => {
139
+ if (word) {
140
+ toks.push({ t: 'atom', s: word });
141
+ word = '';
142
+ }
143
+ };
144
+ for (const ch of str) {
145
+ if (ch === ' ') {
146
+ flush();
147
+ toks.push({ t: 'space' });
148
+ }
149
+ else if (stringWidth(ch) === 2) {
150
+ flush();
151
+ toks.push({ t: 'wide', s: ch }); // CJK / emoji — breakable per char
152
+ }
153
+ else {
154
+ word += ch;
155
+ }
156
+ }
157
+ flush();
158
+ };
159
+ const re = /`[^`]*`/g;
160
+ let last = 0;
161
+ let m;
162
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop idiom
163
+ while ((m = re.exec(text)) !== null) {
164
+ pushText(text.slice(last, m.index));
165
+ toks.push({ t: 'code', s: m[0] }); // backticks kept → atomic during wrap
166
+ last = m.index + m[0].length;
167
+ }
168
+ pushText(text.slice(last));
169
+ return toks;
170
+ }
171
+ function wrapMixed(text, width) {
172
+ const lines = [];
173
+ let cur = '';
174
+ let curW = 0;
175
+ let pendSpace = false;
176
+ for (const tok of tokenizeForWrap(text)) {
177
+ if (tok.t === 'space') {
178
+ if (cur !== '')
179
+ pendSpace = true;
180
+ continue;
181
+ }
182
+ const piece = tok.s;
183
+ const w = stringWidth(tok.t === 'code' ? piece.replace(/`/g, '') : piece);
184
+ const sep = pendSpace ? 1 : 0;
185
+ // Short trailing punctuation sticks to the current line (avoids orphaning a
186
+ // lone "." onto the next line after an over-wide code span).
187
+ const isPunct = tok.t === 'atom' && piece.length <= 2 && /^[.,;:!?)\]}…'"]+$/.test(piece);
188
+ if (cur !== '' && !isPunct && curW + sep + w > width) {
189
+ lines.push(cur);
190
+ cur = piece;
191
+ curW = w;
192
+ pendSpace = false;
193
+ }
194
+ else {
195
+ if (pendSpace) {
196
+ cur += ' ';
197
+ curW += 1;
198
+ pendSpace = false;
199
+ }
200
+ cur += piece;
201
+ curW += w;
202
+ }
203
+ }
204
+ if (cur)
205
+ lines.push(cur);
206
+ return lines;
207
+ }
208
+ // Pitfall / warning cue phrases → bold.yellow (#3, "caution" half). Bilingual,
209
+ // bounded set. Ambiguous words (Warning / 注意 / …) require a trailing colon to
210
+ // avoid lighting up ordinary prose; strong phrases (Pitfall / Common bug) don't.
211
+ const PITFALL_RE = /\b(?:Common bug|Common mistake|Common error|Pitfall|Gotcha|Footgun)\b[::]?|\b(?:Warning|Caution)\b[::]|(?:常见错误|常见陷阱|常见误区|陷阱|注意|警告|误区)[::]/gi;
212
+ function emphasize(text, bold) {
213
+ const base = bold ? chalk.bold.white : chalk.white;
214
+ let out = '';
215
+ let last = 0;
216
+ PITFALL_RE.lastIndex = 0;
217
+ let m;
218
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop idiom
219
+ while ((m = PITFALL_RE.exec(text)) !== null) {
220
+ if (m.index > last)
221
+ out += base(text.slice(last, m.index));
222
+ out += chalk.bold.yellow(m[0]);
223
+ last = m.index + m[0].length;
224
+ }
225
+ if (last < text.length)
226
+ out += base(text.slice(last));
227
+ return out;
228
+ }
229
+ // Strip backticks → inline code cyan; prose via emphasize() (white / bold.white
230
+ // for the lead sentence #3, with pitfall cues lifted to bold.yellow).
231
+ function colorizeInline(s, bold = false) {
232
+ let out = '';
233
+ let last = 0;
234
+ const re = /`([^`]*)`/g;
235
+ let m;
236
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop idiom
237
+ while ((m = re.exec(s)) !== null) {
238
+ if (m.index > last)
239
+ out += emphasize(s.slice(last, m.index), bold);
240
+ out += chalk.cyan(m[1]);
241
+ last = m.index + m[0].length;
242
+ }
243
+ if (last < s.length)
244
+ out += emphasize(s.slice(last), bold);
245
+ return out;
246
+ }
247
+ // Render one body paragraph: ```fenced``` → monospace block (no wrap, cyan);
248
+ // inline `code` → cyan, never split; ASCII diagrams → passthrough; prose → wrap.
249
+ // `bold` paints prose bold.white (used for the lead sentence — #3).
250
+ function renderBodyParagraph(para, bold = false) {
251
+ let inFence = false;
252
+ for (const raw of para.split('\n')) {
253
+ if (/^\s*```/.test(raw)) {
254
+ inFence = !inFence;
255
+ continue; // swallow the fence marker line itself
256
+ }
257
+ if (inFence) {
258
+ console.log(chalk.cyan(' │ ') + chalk.cyan(` ${raw}`));
259
+ continue;
260
+ }
261
+ if (raw === '') {
262
+ emptyLine();
263
+ continue;
264
+ }
265
+ if (isPreformatted(raw)) {
266
+ console.log(chalk.cyan(' │ ') + chalk.white(raw));
267
+ continue;
268
+ }
269
+ for (const ln of wrapMixed(raw, W - 4)) {
270
+ console.log(chalk.cyan(' │ ') + ` ${colorizeInline(ln, bold)}`);
271
+ }
272
+ }
273
+ }
274
+ function wrapText(text, width) {
275
+ const out = [];
276
+ for (const line of text.split('\n')) {
277
+ if (line === '') {
278
+ out.push('');
279
+ continue;
280
+ }
281
+ if (isPreformatted(line)) {
282
+ out.push(line);
283
+ continue;
284
+ }
285
+ let buf = '';
286
+ for (const word of line.split(' ')) {
287
+ if ((buf + ' ' + word).trim().length > width) {
288
+ out.push(buf.trim());
289
+ buf = word;
290
+ }
291
+ else {
292
+ buf = (buf + ' ' + word).trim();
293
+ }
294
+ }
295
+ if (buf)
296
+ out.push(buf);
297
+ }
298
+ return out;
299
+ }
300
+ // Green BLUF "key point" band — sandwiched in two ASCII-dash dividers, placed
301
+ // after the visual slot and before the body. Classic black-green CTF look:
302
+ // green dashes + bold-green sentence. Dashes are ASCII '-' (NOT U+2500, which
303
+ // is EAW-ambiguous → CJK width break). The one-liner is an INDEPENDENT
304
+ // proposition (never spoils the y/n). See docs/learn-card-visuals.md.
305
+ function renderKeyPointBand(keyPoint, cardNum, total) {
306
+ const isZh = lang().startsWith('zh');
307
+ const cols = detectCaps().cols;
308
+ const dashes = '-'.repeat(Math.min(W, Math.max(24, cols - 6)));
309
+ const label = isZh ? '要点' : 'KEY POINT';
310
+ const cardLabel = isZh ? `第 ${cardNum} / ${total} 张` : `Card ${cardNum} / ${total}`;
311
+ console.log(chalk.cyan(' │ ') + chalk.green(dashes));
312
+ console.log(chalk.cyan(' │ ') + chalk.green(`${label} · ${cardLabel}`));
313
+ for (const ln of wrapMixed(keyPoint, W - 4)) {
314
+ console.log(chalk.cyan(' │ ') + chalk.bold.green(' ' + ln));
315
+ }
316
+ console.log(chalk.cyan(' │ ') + chalk.green(dashes));
317
+ emptyLine();
318
+ }
319
+ // Poka-yoke action footer for knowledge cards — persistent guidance every
320
+ // screen (Newbie Path Invariant). Adds the `e` report channel + `b` back +
321
+ // `card N` jump, plus the 防呆 one-liner clarifying `e` is not an answer.
322
+ function renderCardActionFooter(card, curriculum) {
323
+ const isZh = lang().startsWith('zh');
324
+ const cols = detectCaps().cols;
325
+ const dashes = '-'.repeat(Math.min(W, Math.max(24, cols - 6)));
326
+ const sep = chalk.gray(' ');
327
+ const parts = [];
328
+ if (card.check) {
329
+ parts.push(chalk.bold.green('[y]') + chalk.gray(isZh ? ' 是' : ' yes'));
330
+ parts.push(chalk.bold.green('[n]') + chalk.gray(isZh ? ' 否' : ' no'));
331
+ }
332
+ else {
333
+ parts.push(chalk.bold.green('[ok]') + chalk.gray(isZh ? ' 继续' : ' next'));
334
+ }
335
+ parts.push(chalk.bold.yellow('[e]') + chalk.gray(isZh ? ' 报告问题' : ' report'));
336
+ parts.push(chalk.bold.cyan('[b]') + chalk.gray(isZh ? ' 上一张' : ' back'));
337
+ parts.push(chalk.bold.cyan('[card N]') + chalk.gray(isZh ? ' 跳转' : ' jump'));
338
+ parts.push(chalk.gray('[quit]') + chalk.gray(isZh ? ' 退出' : ' quit'));
339
+ console.log(chalk.gray(' ' + dashes));
340
+ console.log(chalk.gray(' ' +
341
+ (isZh ? `第 ${card.number} / ${curriculum.totalCards} 张` : `Card ${card.number} / ${curriculum.totalCards}`)));
342
+ console.log(' ' + parts.join(sep));
343
+ console.log(chalk.gray(' ' + dashes));
344
+ console.log(chalk.gray(' ' +
345
+ (isZh
346
+ ? 'e = 报告本卡错误,不算作答;提交后回到本题继续'
347
+ : 'e = report a problem with this card (not an answer); you return here after')));
348
+ console.log(chalk.gray(' ' + (isZh ? '更多: bookmark · status · lang zh/en' : 'more: bookmark · status · lang zh/en')));
349
+ console.log();
350
+ }
351
+ export function renderKnowledgeCard(card, curriculum) {
352
+ const L = lang();
353
+ const c = localized(card, L);
354
+ cardHeader(c, curriculum);
355
+ emptyLine();
356
+ const titleLines = wrapMixed(c.title, W - 4);
357
+ for (const tl of titleLines)
358
+ bodyLine(chalk.bold.yellow(tl));
359
+ bodyLine(chalk.gray('─'.repeat(Math.min(Math.max(...titleLines.map((l) => stringWidth(l))), W))));
360
+ // Slot order: title → ≤1 visual → green key-point band → body.
361
+ // chart_ids is on the canonical card; localized() preserves it because the
362
+ // _zh overlay only carries title/body/icoaConnection/checkStatement/key_point.
363
+ // MAX-ONE: only the first visual id is rendered (docs/learn-card-visuals.md).
364
+ const hasVisual = !!(card.chart_ids && card.chart_ids.length > 0);
365
+ if (hasVisual) {
366
+ renderCharts(card.chart_ids.slice(0, 1), curriculum.charts, detectCaps());
367
+ }
368
+ if (c.key_point && c.key_point.trim() !== '') {
369
+ if (!hasVisual)
370
+ emptyLine(); // visual already left a trailing blank line
371
+ renderKeyPointBand(c.key_point, c.number, curriculum.totalCards);
372
+ }
373
+ else if (!hasVisual) {
374
+ emptyLine();
375
+ }
376
+ // Drop empty / whitespace-only paragraphs: some cards (e.g. Phase 8) author
377
+ // body arrays with "" elements as separators, which double-space against the
378
+ // emptyLine() the loop already adds between paragraphs (looked like 3 blanks).
379
+ // Filtering first also keeps idx===0 = the first real paragraph (lead bold).
380
+ c.body
381
+ .filter((p) => p != null && p.trim() !== '')
382
+ .forEach((para, idx) => {
383
+ // #3: bold the lead sentence of the first paragraph (the card's takeaway).
384
+ // Sentence end = . ! ? followed by space/EOL (skips decimals like 0.75 and
385
+ // dotted code like np.ndarray) or any CJK terminator 。!?.
386
+ const lead = idx === 0 ? para.match(/^([\s\S]*?(?:[.!?](?=\s|$)|[。!?]))(\s[\s\S]*)?$/) : null;
387
+ if (lead && lead[1].trim().length <= 180) {
388
+ renderBodyParagraph(lead[1], true);
389
+ const rest = lead[2]?.trimStart();
390
+ if (rest)
391
+ renderBodyParagraph(rest, false);
392
+ }
393
+ else {
394
+ renderBodyParagraph(para, false);
395
+ }
396
+ emptyLine();
397
+ });
398
+ if (c.icoaConnection) {
399
+ bodyLine(chalk.magenta(' ' + t('icoa_connection', L)));
400
+ bodyLine(chalk.gray(' ' + '─'.repeat(20)));
401
+ for (const ln of wrapMixed(c.icoaConnection, W - 4)) {
402
+ bodyLine(' ' + chalk.magenta(ln));
403
+ }
404
+ emptyLine();
405
+ }
406
+ // Optional comprehension check — one-keystroke y/n. Pulls the zh override
407
+ // from card._zh when in zh locale (localized() doesn't reach nested fields).
408
+ // Supports BOTH the flat seed shape (`_zh.checkStatement`) AND the raw
409
+ // Claude shape (`_zh.check.statement`) so it works pre- or post-flatten.
410
+ if (card.check) {
411
+ const isZh = L.startsWith('zh');
412
+ const zhStatement = card._zh?.checkStatement ?? card._zh?.check?.statement;
413
+ const statement = isZh ? (zhStatement ?? card.check.statement) : card.check.statement;
414
+ bodyLine(chalk.cyan(' 🤔 ' + (isZh ? '请回答 (输 y 或 n):' : 'Quick comprehension check (type y or n):')));
415
+ bodyLine(chalk.gray(' ' + '─'.repeat(20)));
416
+ for (const ln of wrapMixed(statement, W - 4)) {
417
+ bodyLine(' ' + chalk.white(ln));
418
+ }
419
+ emptyLine();
420
+ }
421
+ cardFooter();
422
+ renderCardActionFooter(card, curriculum);
423
+ }
424
+ // Renders the post-check feedback line. Called from learn.ts after y/n.
425
+ //
426
+ // BASIC tier (default): correct → ✓; wrong → brief ✗ ack ONLY — no answer
427
+ // reveal, no explanation, no pause. Both auto-advance (the card is recorded
428
+ // for the phase-end score either way). Revealing the answer + explanation on
429
+ // a wrong y/n is a PREMIUM learn-token feature (pass premiumExplain=true).
430
+ // See [[project_card_feedback_e]] feedback-tiering.
431
+ export function renderCheckFeedback(card, userAnswer, correct, premiumExplain = false) {
432
+ const isZh = lang().startsWith('zh');
433
+ console.log();
434
+ if (correct) {
435
+ console.log(chalk.green(' ✓ ') + chalk.white(isZh ? '答对了。' : 'Correct.'));
436
+ }
437
+ else {
438
+ console.log(chalk.yellow(' ✗ ') + chalk.white(isZh ? '记下了,继续。' : 'Noted — moving on.'));
439
+ if (premiumExplain) {
440
+ const right = card.check?.answer === 'y' ? (isZh ? '是' : 'Yes') : isZh ? '否' : 'No';
441
+ console.log(chalk.gray(' ' +
442
+ (isZh ? '正确答案: ' : 'Answer: ') +
443
+ right +
444
+ '. ' +
445
+ (isZh ? '回看上面那段。' : 'Re-read the paragraph above.')));
446
+ }
447
+ }
448
+ // Worked solution (problem cards): revealed in ALL tiers after the check, both
449
+ // right and wrong — on a problem card the worked solution IS the lesson. _zh
450
+ // overlay wins when present. The card pauses after this (learn.ts) so there is
451
+ // time to read it. Independent of premiumExplain (that gates a one-line hint).
452
+ const solution = isZh ? (card._zh?.solution ?? card.solution) : card.solution;
453
+ if (solution && solution.length > 0) {
454
+ console.log();
455
+ console.log(chalk.cyan(' 📝 ' + (isZh ? '解答:' : 'Worked solution:')));
456
+ for (const para of solution) {
457
+ for (const ln of wrapMixed(para, W - 4)) {
458
+ console.log(chalk.white(' ' + ln));
459
+ }
460
+ console.log();
461
+ }
462
+ }
463
+ console.log();
464
+ }
465
+ // Founder sign-off shown at the BOTTOM of score screens (peak-end placement —
466
+ // the name lands at the satisfaction moment, like a film's closing "Directed by").
467
+ // Dim, recedes below the score. NO "Author" (bodies are AI-generated → dispute
468
+ // surface); "Architect" states the real, undisputed contribution. NO jargon.
469
+ // See memory project_learn_attribution_banner_plan.
470
+ function attributionLines(isZh) {
471
+ return [
472
+ chalk.white(` ${isZh ? '编著:Charlie Zhu' : 'Created & edited by Charlie Zhu'}`),
473
+ chalk.gray(` ${isZh ? 'ICOA 2026 创始人 · 课程总架构师' : 'Founder & Chief Curriculum Architect · ICOA 2026'}`),
474
+ ];
475
+ }
476
+ // Phase-end score screen — basic-tier feedback granularity (project_card_feedback_e):
477
+ // shown when a forward advance crosses a module boundary. Computes the y/n score
478
+ // for the just-completed phase and announces the next one. Yellow milestone-style
479
+ // band; ASCII dashes (CJK-safe). Does NOT pause — flows into the next card.
480
+ export function renderPhaseComplete(curriculum, state, done, next) {
481
+ const isZh = lang().startsWith('zh');
482
+ const [lo, hi] = done.cardRange;
483
+ const checks = state.checkResults || {};
484
+ let answered = 0;
485
+ let correct = 0;
486
+ for (let n = lo; n <= hi; n++) {
487
+ const r = checks[String(n)];
488
+ if (r) {
489
+ answered++;
490
+ if (r.correct)
491
+ correct++;
492
+ }
493
+ }
494
+ const dashes = '-'.repeat(48);
495
+ console.log();
496
+ for (const l of icoaBannerLines(' '))
497
+ console.log(l);
498
+ console.log();
499
+ console.log(chalk.bold.yellow(' ' + dashes));
500
+ console.log(chalk.bold.yellow(' ' + (isZh ? `阶段 ${done.number} 完成` : `Phase ${done.number} complete`)) +
501
+ chalk.gray(' · ') +
502
+ chalk.white(done.name));
503
+ const scoreColor = answered > 0 && correct === answered ? chalk.bold.green : chalk.bold.white;
504
+ console.log(chalk.gray(' ' + (isZh ? '理解检查得分: ' : 'comprehension score: ')) +
505
+ scoreColor(`${correct}/${answered}`) +
506
+ chalk.gray(answered === 0 ? (isZh ? ' (本阶段未作答 y/n)' : ' (no y/n answered)') : ''));
507
+ if (next) {
508
+ console.log(chalk.gray(' ' + (isZh ? '下一阶段: ' : 'next: ')) +
509
+ chalk.bold.cyan(isZh ? `阶段 ${next.number}` : `Phase ${next.number}`) +
510
+ chalk.gray(' · ') +
511
+ chalk.white(next.name));
512
+ }
513
+ console.log(chalk.bold.yellow(' ' + dashes));
514
+ for (const l of attributionLines(isZh))
515
+ console.log(l);
516
+ }
517
+ // Curriculum-end OVERALL score — shown after the last card of the last phase.
518
+ // Per-phase y/n breakdown + a grand total across all modules. Green CTF band.
519
+ export function renderCurriculumComplete(curriculum, state) {
520
+ const isZh = lang().startsWith('zh');
521
+ const checks = state.checkResults || {};
522
+ const dashes = '-'.repeat(48);
523
+ let totA = 0;
524
+ let totC = 0;
525
+ console.log();
526
+ for (const l of icoaBannerLines(' '))
527
+ console.log(l);
528
+ console.log();
529
+ console.log(chalk.bold.green(' ' + dashes));
530
+ console.log(chalk.bold.green(' 🏆 ' + (isZh ? `${curriculum.name} — 全部完成!` : `${curriculum.name} — complete!`)));
531
+ console.log(chalk.bold.green(' ' + dashes));
532
+ for (const m of curriculum.modules) {
533
+ const [lo, hi] = m.cardRange;
534
+ let a = 0;
535
+ let c = 0;
536
+ for (let n = lo; n <= hi; n++) {
537
+ const r = checks[String(n)];
538
+ if (r) {
539
+ a++;
540
+ if (r.correct)
541
+ c++;
542
+ }
543
+ }
544
+ totA += a;
545
+ totC += c;
546
+ const col = a > 0 && c === a ? chalk.green : chalk.white;
547
+ console.log(chalk.gray(' ' + (isZh ? `阶段 ${m.number}` : `Phase ${m.number}`).padEnd(8)) +
548
+ col(`${c}/${a}`.padEnd(7)) +
549
+ chalk.gray(m.name));
550
+ }
551
+ console.log(chalk.bold.green(' ' + dashes));
552
+ console.log(chalk.bold.green(' ' + (isZh ? '总分 / overall: ' : 'Overall: ')) + chalk.bold.white(`${totC}/${totA}`));
553
+ console.log(chalk.bold.green(' ' + dashes));
554
+ for (const l of attributionLines(isZh))
555
+ console.log(l);
556
+ console.log();
557
+ }
558
+ export function renderMCQCard(card, curriculum) {
559
+ const L = lang();
560
+ const c = localized(card, L);
561
+ cardHeader(c, curriculum);
562
+ emptyLine();
563
+ bodyLine(chalk.bold.yellow('🎯 ' + c.title));
564
+ bodyLine(chalk.gray('─'.repeat(Math.min(c.title.length + 4, W))));
565
+ emptyLine();
566
+ for (const ln of wrapText(c.question, W - 4))
567
+ bodyLine(' ' + ln);
568
+ emptyLine();
569
+ for (const letter of ['A', 'B', 'C', 'D']) {
570
+ bodyLine(chalk.cyan(` ${letter}.`) + ' ' + chalk.white(c.options[letter]));
571
+ }
572
+ emptyLine();
573
+ cardFooter();
574
+ console.log(chalk.gray(' ' + (L.startsWith('zh') ? '输入 ' : 'Type ')) +
575
+ chalk.bold.green('A') +
576
+ chalk.gray(' / ') +
577
+ chalk.bold.green('B') +
578
+ chalk.gray(' / ') +
579
+ chalk.bold.green('C') +
580
+ chalk.gray(' / ') +
581
+ chalk.bold.green('D') +
582
+ chalk.gray(' ' + t('type_to_answer', L)));
583
+ console.log();
584
+ }
585
+ export function renderMCQFeedback(card, selected, correct, state) {
586
+ const L = lang();
587
+ const c = localized(card, L);
588
+ console.log();
589
+ if (correct) {
590
+ console.log(' ' +
591
+ chalk.bold.green(t('correct', L) + ' ') +
592
+ chalk.gray(`${t('one_point', L)} · ${selected} = ${c.options[selected]}`));
593
+ }
594
+ else {
595
+ console.log(' ' +
596
+ chalk.bold.red(t('not_quite', L) + ' ') +
597
+ chalk.gray(`${t('you_chose', L)} ${selected}; ${t('answer_is', L)} ${c.answer}.`));
598
+ }
599
+ console.log();
600
+ console.log(chalk.gray(' ' + t('explanation', L)));
601
+ for (const ln of wrapText(c.explanation, W - 4)) {
602
+ console.log(chalk.gray(' ' + ln));
603
+ }
604
+ const mcqEntries = Object.values(state.mcqResults);
605
+ const correctCount = mcqEntries.filter((e) => e.correct).length;
606
+ console.log();
607
+ console.log(chalk.gray(' ' + t('mcq_accuracy_so_far', L) + ' ') + chalk.white(`${correctCount}/${mcqEntries.length}`));
608
+ console.log();
609
+ // Correct answers auto-advance (caller bumps the card); only prompt for a
610
+ // manual continue when wrong, so the student can re-read the explanation.
611
+ if (!correct) {
612
+ console.log(chalk.gray(' ' + t('press_ok_continue', L) + ' ') +
613
+ chalk.bold.green('ok') +
614
+ chalk.gray(' ' + t('to_continue', L)));
615
+ console.log();
616
+ }
617
+ }
618
+ export function renderPracticalCard(card, curriculum) {
619
+ const L = lang();
620
+ const c = localized(card, L);
621
+ cardHeader(c, curriculum);
622
+ emptyLine();
623
+ bodyLine(chalk.bold.yellow('🛠 ' + c.title));
624
+ bodyLine(chalk.gray('─'.repeat(Math.min(c.title.length + 4, W))));
625
+ emptyLine();
626
+ for (const ln of wrapText(c.task, W - 4))
627
+ bodyLine(' ' + ln);
628
+ if (c.starterCode) {
629
+ emptyLine();
630
+ bodyLine(chalk.gray(' ' + t('starter_code', L)));
631
+ emptyLine();
632
+ for (const ln of c.starterCode.split('\n')) {
633
+ bodyLine(' ' + chalk.cyan(ln));
634
+ }
635
+ }
636
+ emptyLine();
637
+ cardFooter();
638
+ console.log(chalk.gray(' ' + t('try_in_sandbox', L) + ' ') +
639
+ chalk.bold.cyan('!python3') +
640
+ chalk.gray(' ' + t('drops_into_python', L)));
641
+ console.log(chalk.gray(' ' + t('when_done', L)));
642
+ console.log(chalk.gray(' ') + chalk.bold.green('done') + chalk.gray(' ' + t('done_desc', L)));
643
+ console.log(chalk.gray(' ') + chalk.bold.yellow('skip') + chalk.gray(' ' + t('skip_desc', L)));
644
+ console.log(chalk.gray(' card N jump to a card by number'));
645
+ console.log();
646
+ }
647
+ export function renderPracticalSuccess(card) {
648
+ const L = lang();
649
+ const c = localized(card, L);
650
+ console.log();
651
+ console.log(' ' + chalk.bold.green(t('practical_recorded', L)));
652
+ console.log();
653
+ console.log(chalk.gray(' ' + t('reference_answer', L)));
654
+ for (const ln of wrapText(c.successHint, W - 4)) {
655
+ console.log(chalk.gray(' ' + ln));
656
+ }
657
+ console.log();
658
+ console.log(chalk.gray(' ' + t('press_ok_continue', L) + ' ') + chalk.bold.green('ok') + chalk.gray(' ' + t('to_continue', L)));
659
+ console.log();
660
+ }
661
+ export function renderSimDemoCard(card, curriculum) {
662
+ const L = lang();
663
+ const c = localized(card, L);
664
+ cardHeader(c, curriculum);
665
+ emptyLine();
666
+ bodyLine(chalk.bold.yellow('🎬 ' + c.title));
667
+ emptyLine();
668
+ for (const ln of wrapText(c.description, W - 4))
669
+ bodyLine(' ' + ln);
670
+ emptyLine();
671
+ bodyLine(chalk.gray(' ' + t('sim_requires', L)));
672
+ emptyLine();
673
+ cardFooter();
674
+ // Action prompts — same style as knowledge cards so students always
675
+ // know how to advance. Without these the sim_demo is a dead-end.
676
+ console.log(chalk.gray(' ') + chalk.bold.cyan('sim') + chalk.gray(' ' + t('sim_launch_desc', L)));
677
+ console.log(chalk.gray(' ') +
678
+ chalk.bold.green('ok') +
679
+ chalk.gray(' / ') +
680
+ chalk.bold.green('next') +
681
+ chalk.gray(' ' + t('continue_to_next', L)));
682
+ console.log(chalk.gray(' bookmark ' + t('bookmark_desc', L)));
683
+ console.log(chalk.gray(' back ' + t('back_desc', L)));
684
+ console.log(chalk.gray(' card N jump to a card by number'));
685
+ console.log(chalk.gray(' quit ' + t('quit_desc', L)));
686
+ console.log();
687
+ }
688
+ // ────────────────────────────────────────────────────────────────────────────
689
+ // Arena task (<track>-frontier) — the READING surface. Hands-on doing lives
690
+ // in the standalone `icoa arena [track] [task]` command (mirrors sim_demo → sim).
691
+ // ────────────────────────────────────────────────────────────────────────────
692
+ export function renderArenaTaskCard(card, curriculum) {
693
+ const L = lang();
694
+ const isZh = L.startsWith('zh');
695
+ const c = localized(card, L);
696
+ cardHeader(c, curriculum);
697
+ emptyLine();
698
+ bodyLine(chalk.bold.cyan('🏟 ' + c.title));
699
+ bodyLine(chalk.gray(' ' + c.origin + ' · ' + c.domain + ' · ' + c.metric));
700
+ emptyLine();
701
+ for (const para of c.statement) {
702
+ for (const ln of wrapText(para, W - 4))
703
+ bodyLine(' ' + ln);
704
+ emptyLine();
705
+ }
706
+ bodyLine(chalk.bold.green(isZh ? ' 核心要点 — baseline → 解法的关键变化' : ' CORE DELTA — baseline → solution'));
707
+ for (const d of c.core_delta) {
708
+ for (const ln of wrapText('• ' + d, W - 6))
709
+ bodyLine(' ' + chalk.white(ln));
710
+ }
711
+ emptyLine();
712
+ bodyLine(chalk.gray((isZh ? ' 提交格式: ' : ' Submission: ') + c.submission_format));
713
+ if (c.hints) {
714
+ bodyLine(chalk.gray(isZh
715
+ ? ' 本题有 5 级提示 (a–e),进入 arena 后逐级解锁。'
716
+ : ' 5 progressive hints (a–e) unlock inside the arena.'));
717
+ }
718
+ emptyLine();
719
+ cardFooter();
720
+ console.log(chalk.gray(' ') +
721
+ chalk.bold.cyan('arena ' + c.task_id) +
722
+ chalk.gray(isZh ? ' 动手做这道题(下载 / 提示 / 提交 / 榜单)' : ' open the arena (download / hints / submit / board)'));
723
+ console.log(chalk.gray(' ') +
724
+ chalk.bold.green('ok') +
725
+ chalk.gray(' / ') +
726
+ chalk.bold.green('next') +
727
+ chalk.gray(isZh ? ' 继续下一张' : ' continue to next'));
728
+ console.log(chalk.gray(' back ' + (isZh ? '上一张' : 'previous')));
729
+ console.log(chalk.gray(' card N jump to a card by number'));
730
+ console.log(chalk.gray(' quit ' + (isZh ? '退出' : 'exit')));
731
+ console.log();
732
+ }
733
+ // ────────────────────────────────────────────────────────────────────────────
734
+ // Coming-soon placeholder (frontier-120 partial publish)
735
+ // ────────────────────────────────────────────────────────────────────────────
736
+ export function renderComingSoonCard(card, curriculum) {
737
+ const L = lang();
738
+ const isZh = L.startsWith('zh');
739
+ const c = localized(card, L);
740
+ cardHeader(c, curriculum);
741
+ emptyLine();
742
+ bodyLine(chalk.bold.yellow('🔜 ' + c.title));
743
+ emptyLine();
744
+ const note = c.note || (isZh ? '这道竞技场题目正在编写中,敬请期待。' : 'This arena task is coming soon.');
745
+ for (const ln of wrapText(note, W - 4))
746
+ bodyLine(' ' + chalk.gray(ln));
747
+ emptyLine();
748
+ cardFooter();
749
+ console.log(chalk.gray(' ') +
750
+ chalk.bold.green('ok') +
751
+ chalk.gray(' / ') +
752
+ chalk.bold.green('next') +
753
+ chalk.gray(isZh ? ' 继续' : ' continue'));
754
+ console.log(chalk.gray(' card N jump to a card by number'));
755
+ console.log(chalk.gray(' quit ' + (isZh ? '退出' : 'exit')));
756
+ console.log();
757
+ }
758
+ // ────────────────────────────────────────────────────────────────────────────
759
+ // Milestone — the big celebration
760
+ // ────────────────────────────────────────────────────────────────────────────
761
+ export function renderMilestone(card, curriculum) {
762
+ const L = lang();
763
+ const c = localized(card, L);
764
+ const isFinal = card.number === curriculum.totalCards;
765
+ console.log();
766
+ console.log(chalk.bold.yellow(' ╭' + rule('═')));
767
+ console.log(chalk.bold.yellow(' ║'));
768
+ console.log(chalk.bold.yellow(' ║ ') + chalk.white(' ' + t('milestone_header', L)));
769
+ console.log(chalk.bold.yellow(' ║'));
770
+ const badgeLine = ` ${c.badge} ${c.emoji}`;
771
+ console.log(chalk.bold.yellow(' ║ ') + chalk.bold.green(badgeLine));
772
+ console.log(chalk.bold.yellow(' ║ ') + chalk.gray(' ─'.repeat(Math.max(1, Math.floor(c.badge.length / 2 + 2)))));
773
+ console.log(chalk.bold.yellow(' ║'));
774
+ console.log(chalk.bold.yellow(' ║ ') + ' ' + chalk.gray(t('in_wild_corresponds', L)));
775
+ for (const ln of wrapText(c.realWorldLevel, W - 4)) {
776
+ console.log(chalk.bold.yellow(' ║ ') + ' ' + chalk.white(ln));
777
+ }
778
+ console.log(chalk.bold.yellow(' ║'));
779
+ console.log(chalk.bold.yellow(' ║ ') + ' ' + chalk.gray(t('whats_next', L)));
780
+ for (const ln of wrapText(c.unlockedNext, W - 4)) {
781
+ console.log(chalk.bold.yellow(' ║ ') + ' ' + chalk.white(ln));
782
+ }
783
+ console.log(chalk.bold.yellow(' ║'));
784
+ console.log(chalk.bold.yellow(' ╰' + rule('═')));
785
+ console.log();
786
+ if (isFinal) {
787
+ // Curriculum-end ceremony — what LEARNDEMO01 + n=100/480 ends with.
788
+ console.log(chalk.gray(' ' + t('demo_complete', L)));
789
+ console.log();
790
+ console.log(chalk.gray(' ' + t('unlock_full', L) + ' ') + chalk.white(t('curriculum_name', L)) + chalk.gray(','));
791
+ console.log(chalk.gray(' ' + t('contact_team_leader', L) + ' ') +
792
+ chalk.bold.yellow('EA') +
793
+ chalk.gray(' ' + t('learn_token', L)));
794
+ console.log(chalk.gray(' ' + t('or_email', L) + ' ') +
795
+ chalk.cyan('australia@icoa2026.au') +
796
+ chalk.gray(' ' + t('for_partnership', L)));
797
+ console.log();
798
+ console.log(chalk.gray(' ' + t('type_quit', L) + ' ') +
799
+ chalk.bold.green('quit') +
800
+ chalk.gray(' ' + t('to_exit', L) + ' ') +
801
+ chalk.bold.green('status') +
802
+ chalk.gray(' ' + t('for_dashboard', L)));
803
+ }
804
+ else {
805
+ // Mid-curriculum phase-end — must show advance prompt, not "quit".
806
+ console.log(chalk.gray(' ') +
807
+ chalk.bold.green('ok') +
808
+ chalk.gray(' / ') +
809
+ chalk.bold.green('next') +
810
+ chalk.gray(' ' + t('continue_to_next', L)));
811
+ console.log(chalk.gray(' status ' + t('status_full', L)));
812
+ console.log(chalk.gray(' card N jump to a card by number'));
813
+ console.log(chalk.gray(' quit ' + t('quit_desc', L)));
814
+ }
815
+ console.log();
816
+ }
817
+ // ────────────────────────────────────────────────────────────────────────────
818
+ // Status dashboard
819
+ // ────────────────────────────────────────────────────────────────────────────
820
+ export function renderStatus(curriculum, state) {
821
+ const L = lang();
822
+ const done = state.cardsCompleted.length;
823
+ const total = curriculum.totalCards;
824
+ const mcqEntries = Object.values(state.mcqResults);
825
+ const mcqCorrect = mcqEntries.filter((e) => e.correct).length;
826
+ console.log();
827
+ console.log(chalk.cyan(' ╭─ ') + chalk.bold.white(t('status_title', L)) + ' ' + chalk.cyan('─'.repeat(25)));
828
+ emptyLine();
829
+ bodyLine(' ' + chalk.gray(t('total_progress', L) + ' ') + progressBar(done, total) + chalk.gray(` (${done}/${total})`));
830
+ bodyLine(' ' +
831
+ chalk.gray(t('streak', L) + ' ') +
832
+ fireStreak(state.streakDays) +
833
+ chalk.gray(` (${t('longest', L)} ${state.longestStreak})`));
834
+ bodyLine(' ' + chalk.gray(t('mcq_accuracy', L) + ' ') + chalk.white(`${mcqCorrect}/${mcqEntries.length}`));
835
+ bodyLine(' ' + chalk.gray(t('practicals_done', L) + ' ') + chalk.white(`${state.practicalsCompleted.length}`));
836
+ bodyLine(' ' + chalk.gray(t('bookmarked', L) + ' ') + chalk.white(`${state.bookmarks.length}`));
837
+ emptyLine();
838
+ const moduleLabel = t('module', L).replace(':', '').trim();
839
+ for (const m of curriculum.modules) {
840
+ const [lo, hi] = m.cardRange;
841
+ const inRange = state.cardsCompleted.filter((c) => c >= lo && c <= hi).length;
842
+ const moduleTotal = hi - lo + 1;
843
+ const status = inRange === moduleTotal ? chalk.green('✓') : inRange > 0 ? chalk.yellow('▶') : chalk.gray('□');
844
+ bodyLine(' ' +
845
+ status +
846
+ ' ' +
847
+ chalk.gray(`${moduleLabel} ${m.number}: `) +
848
+ chalk.white(`${inRange}/${moduleTotal}`) +
849
+ chalk.gray(' ' + m.name));
850
+ }
851
+ emptyLine();
852
+ if (state.achievements.length > 0) {
853
+ bodyLine(' ' + chalk.gray(t('achievements', L)));
854
+ for (const a of state.achievements) {
855
+ bodyLine(' ' + chalk.bold.yellow('★ ') + chalk.white(a));
856
+ }
857
+ }
858
+ else {
859
+ bodyLine(' ' + chalk.gray(t('achievements_none', L)));
860
+ }
861
+ emptyLine();
862
+ cardFooter();
863
+ }